diff --git a/.github/workflows/build_supportcompanion.yml b/.github/workflows/build_supportcompanion.yml
index 68c2199..6b17c1e 100644
--- a/.github/workflows/build_supportcompanion.yml
+++ b/.github/workflows/build_supportcompanion.yml
@@ -27,6 +27,23 @@ jobs:
with:
fetch-depth: 0
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode_26.6.app
+
+ - name: Run tests
+ # Signed ad hoc on purpose. The Debug configuration expects a Developer ID certificate
+ # the runner does not have, and the tests need none. Debug is also the only configuration
+ # that relaxes library validation, without which the hardened runtime refuses to load an
+ # ad-hoc signed test bundle into the app.
+ run: |
+ xcodebuild test \
+ -project SupportCompanion.xcodeproj \
+ -scheme SupportCompanion \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ CODE_SIGN_IDENTITY=- \
+ DEVELOPMENT_TEAM=
+
- name: Install Apple Xcode certificates
uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
@@ -72,6 +89,7 @@ jobs:
with:
name: SupportCompanion ${{env.SC_VERSION}}
tag_name: v${{env.SC_VERSION}}
+ target_commitish: ${{ github.sha }}
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/build_supportcompanion_manual.yml b/.github/workflows/build_supportcompanion_manual.yml
index 0a3fa35..e0ecad2 100644
--- a/.github/workflows/build_supportcompanion_manual.yml
+++ b/.github/workflows/build_supportcompanion_manual.yml
@@ -18,6 +18,23 @@ jobs:
with:
fetch-depth: 0
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode_26.6.app
+
+ - name: Run tests
+ # Signed ad hoc on purpose. The Debug configuration expects a Developer ID certificate
+ # the runner does not have, and the tests need none. Debug is also the only configuration
+ # that relaxes library validation, without which the hardened runtime refuses to load an
+ # ad-hoc signed test bundle into the app.
+ run: |
+ xcodebuild test \
+ -project SupportCompanion.xcodeproj \
+ -scheme SupportCompanion \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ CODE_SIGN_IDENTITY=- \
+ DEVELOPMENT_TEAM=
+
- name: Install Apple Xcode certificates
uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
@@ -63,6 +80,7 @@ jobs:
with:
name: SupportCompanion ${{env.SC_VERSION}}
tag_name: v${{env.SC_VERSION}}
+ target_commitish: ${{ github.sha }}
draft: false
prerelease: false
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/build_supportcompanion_prerelease.yml b/.github/workflows/build_supportcompanion_prerelease.yml
index 259a8da..a71f4b9 100644
--- a/.github/workflows/build_supportcompanion_prerelease.yml
+++ b/.github/workflows/build_supportcompanion_prerelease.yml
@@ -18,6 +18,23 @@ jobs:
with:
fetch-depth: 0
+ - name: Select Xcode
+ run: sudo xcode-select -s /Applications/Xcode_26.6.app
+
+ - name: Run tests
+ # Signed ad hoc on purpose. The Debug configuration expects a Developer ID certificate
+ # the runner does not have, and the tests need none. Debug is also the only configuration
+ # that relaxes library validation, without which the hardened runtime refuses to load an
+ # ad-hoc signed test bundle into the app.
+ run: |
+ xcodebuild test \
+ -project SupportCompanion.xcodeproj \
+ -scheme SupportCompanion \
+ -configuration Debug \
+ -destination 'platform=macOS' \
+ CODE_SIGN_IDENTITY=- \
+ DEVELOPMENT_TEAM=
+
- name: Install Apple Xcode certificates
uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
@@ -63,6 +80,7 @@ jobs:
with:
name: SupportCompanion ${{env.SC_VERSION}}
tag_name: v${{env.SC_VERSION}}
+ target_commitish: ${{ github.sha }}
draft: false
prerelease: true
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.gitignore b/.gitignore
index 8f75ade..6efb68a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,4 +46,5 @@ __pycache__/
build/
release/
.build/
-buildServer.json
\ No newline at end of file
+buildServer.json
+.compile
\ No newline at end of file
diff --git a/AppIcon.icon/icon.json b/AppIcon.icon/icon.json
index e2930d9..9496262 100644
--- a/AppIcon.icon/icon.json
+++ b/AppIcon.icon/icon.json
@@ -1,15 +1,12 @@
{
"fill" : {
- "linear-gradient" : [
- "extended-srgb:0.68627,0.32157,0.87059,1.00000",
- "extended-srgb:0.00000,0.47843,1.00000,1.00000"
- ]
+ "solid" : "srgb:0.43137,0.29412,0.96078,1.00000"
},
"groups" : [
{
"blend-mode-specializations" : [
{
- "value" : "overlay"
+ "value" : "normal"
},
{
"appearance" : "dark",
@@ -25,7 +22,7 @@
{
"blend-mode-specializations" : [
{
- "value" : "normal"
+ "value" : "lighten"
},
{
"appearance" : "tinted",
@@ -33,12 +30,17 @@
}
],
"fill-specializations" : [
+ {
+ "value" : {
+ "solid" : "extended-gray:1.00000,1.00000"
+ }
+ },
{
"appearance" : "tinted",
"value" : "automatic"
}
],
- "glass" : true,
+ "glass" : false,
"hidden" : false,
"image-name" : "support_agent_800dp_E3E3E3_FILL0_wght400_GRAD0_opsz48.png",
"name" : "support_agent_800dp_E3E3E3_FILL0_wght400_GRAD0_opsz48",
@@ -78,6 +80,7 @@
"kind" : "neutral",
"opacity" : 0.5
},
+ "specular" : true,
"translucency" : {
"enabled" : false,
"value" : 0.5
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d48241..9bfc065 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,234 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+## [3.0.0] - 2026-09-25
+### Breaking changes
+- **Privileged settings are only read when set by an administrator.** `IsPrivileged` on `Actions`, `RequirePrivilegedActionAuthentication`, and the elevation settings (`EnableElevation`, `MaxElevationTime`, `RequireResonForElevation`, `ReasonMinLength`, `ElevationWebhookUrl`, `ElevationSeverity`) are now only honored when they come from an MDM configuration profile. These settings decide what runs as root and who gets administrator rights, so honoring them from a domain the user can write to was a local privilege escalation. `/Library/Preferences/com.github.macadmins.SupportCompanion.plist` is **no longer trusted for these keys either**: only root can write it, which is exactly the problem — anything that reaches root once, a privileged action or somebody inside an elevation window, could write itself a permanent grant there and nothing would put it back. A profile is owned by the MDM, which re-applies it. Actions defined in the user's own preferences still run, but never with privileges, and `RequirePrivilegedActionAuthentication` defaults to `true` unless an administrator sets it. **If you deploy these settings with `defaults write`, to either the user's domain or `/Library/Preferences`, move them to a configuration profile.** A key that is ignored because of where it lives is logged, so a setting that appears to have stopped working can be confirmed from the log.
+- **Fleet mode is selected automatically** on Macs with Fleet's agent (orbit) installed when no other mode matches. Before, these Macs used System Profiler mode. Set `Mode` explicitly to keep a different mode.
+- **The privileged helper is installed by the package's `helper_install.zsh`**, which places it in `/Library/PrivilegedHelperTools` and loads its LaunchDaemon. `SMJobBless` is deprecated in macOS 14 and later; `SMAppService` registration remains only as a fallback for a Mac where the packaged helper is missing, and is skipped entirely when `SkipHelperInstall` says the helper is deployed declaratively.
+- **Existing helper installs:** the package now replaces the helper on every install, unloading the old one first and loading the new one, and the install fails if the helper does not end up running. The app and the helper speak a versioned interface, so a Mac left with an older helper is not merely stale — every privileged operation fails — and that must not pass silently.
+- **The helper only serves 3.0.0 and later clients.** A valid signature proves only that a connecting app is a Support Companion build signed by us, which every 2.x release satisfies too. Signature alone therefore cannot tell this version's client apart from an older one that enforced less, so the restrictions added here would not hold if an earlier build could still connect. The helper now checks the connecting app's version, requires it to be signed with the hardened runtime, and requires it to run from `/Applications/SupportCompanion.app`, which a standard user cannot write to.
+- **The helper no longer runs commands on request.** Its interface was a pair of methods that ran any command or script as root, so every restriction on what could run — `IsPrivileged`, `EnableElevation` — was enforced only in the app, and any code running in the app process was equivalent to root. It now exposes named operations instead, and decides what each one runs. For privileged actions the app sends only the action's name; the helper looks the `Command` up in the administrator-managed preferences itself, so what runs as root always comes from an administrator. `EnableElevation` is re-checked in the helper rather than only hiding a button.
+
+### Security
+- **Temporary administrator rights are enforced by the helper, not the app.** The demotion timer used to run in the app and keep its deadline in `PrivilegeDemotionEndTime` in the user's own preferences, so quitting the app or deleting the key left the user an administrator indefinitely. Both the deadline and the timer live in the helper now, in a root-owned file under `/var/db/com.github.macadmins.SupportCompanion`, and demotion happens whether or not the app is running — including catching up on a deadline that passed while the Mac was off. The app's timer only drives the countdown.
+- **Administrator rights granted to other accounts during an elevation are revoked.** Demoting the elevated account does nothing about a second administrator created while the window is open, which would outlive the elevation entirely. The helper watches the `admin` group for the length of the window, by short name and by UUID, and takes back rights granted to anyone who was not already an administrator when the window opened, repeatedly if they are granted again. The elevated user keeps their own rights until the timer runs out. Every grant and revocation is written to the root-owned log.
+- **Elevation reasons are also recorded where the user cannot edit them.** The reason log in the user's Application Support folder is writable by that user, so it was never an audit trail. The helper keeps its own root-owned log alongside the elevation state. Reasons are flattened to a single line and bounded in length before being written, so a reason containing newlines cannot forge entries in that log, and one containing a great deal of text cannot inflate it.
+- **Uninstalling hands back any administrator rights it was holding.** The uninstaller removes the helper, which is what would have taken those rights away, so uninstalling during an elevation window previously left the elevated user a permanent administrator. It now demotes anyone still elevated before removing anything, and clears the helper's state directory.
+- **More than one user can be elevated at a time.** With fast user switching two accounts can each be logged in and each ask for rights. Each window is now tracked separately with its own deadline and timer, rather than the second replacing the first and leaving that user elevated with nothing left to demote them.
+- The package's preinstall script now verifies that the 1.x `Uninstall.sh` it runs is owned by root and not writable by group or others before executing it.
+
+### Added
+- **`EnforceAdminAllowlist` and `PermanentAdmins`, to stop an elevation outliving its window.** The helper's deadline lives in a root-owned file, and a user who is briefly an administrator is briefly root, so they can delete it and restart the helper — leaving nothing to say a demotion was owed, and no way to tell their rights from a permanent administrator's. With an allowlist set, the helper reconciles the `admin` group at startup and every five minutes: anyone holding administrator rights who is neither in `PermanentAdmins` nor inside a live elevation window is demoted. Deleting the state file then removes the only evidence that an elevation was legitimate, so it ends the elevation rather than extending it. `root` is always permitted and never demoted, and the policy is re-read on every pass, so removing the profile only suspends enforcement while it is actually missing.
+
+ **`EnforceAdminAllowlist` defaults to off, and `PermanentAdmins` must list every account that should keep administrator rights before it is turned on** — management accounts, break-glass accounts and permanently-admin staff included. This includes accounts granted administrator rights by something else, such as Platform SSO's `AdministratorGroups`: anything not on the list is demoted, and an identity provider that grants it again simply produces a demotion every five minutes. With it on and the list incomplete, those accounts are demoted within five minutes. An explicitly empty list is honored, meaning no account is permanently an administrator; a missing list is refused with an error rather than acted on. Both keys must come from a **device-scoped** configuration profile, since reconciliation runs when there is no logged-in user to attribute it to.
+
+ Administrator group members that cannot be resolved to an account are reported and left alone rather than removed, since a failed lookup is not evidence that an entry does not belong. Entries that appear while an elevation is open are still revoked, because there the group's earlier state establishes that they are new.
+
+ This is containment rather than prevention. Brief root access has other routes to persistence — a launch daemon, a sudoers drop-in, enabling the root account — which group membership does not show. Treat elevation as a speed bump with an audit trail, not as a boundary.
+- **The helper can be deployed declaratively.** With `com.apple.configuration.services.background-tasks` (macOS 15+, supervised), the helper and its launchd job are placed in `/var/db/ManagedConfigurationFiles`, which the system will not let even root write to, and the job cannot be unloaded or disabled — so an elevated user cannot stop the process that will demote them. `DDM/make_ddm_assets.zsh` builds the archive, the launchd job and the declarations from a built app. This is the recommended deployment wherever `EnableElevation` is used.
+- `SkipHelperInstall`, which stops the package installing and loading its own copy of the helper, for Macs where it is deployed declaratively. Set it in a **device-scoped** configuration profile: a user-scoped one is not readable by an installer script, and `/Library/Preferences` is not read for this key either. Setting it removes the helper and its launchd job, so honoring it from a root-writable file would let one root moment disable the component that demotes elevated users. The installer script logs the key when it finds it somewhere it is not read from.
+- `ElevationAllowedAdmins`, a list of account names the elevation watchdog ignores, for management accounts an MDM may legitimately add while somebody is elevated. Read from a **device-scoped** configuration profile, like the other elevation enforcement keys.
+- While administrator rights are held, the time left counts down next to the tray menu icon, so it is visible without opening anything. The tooltip offers to demote immediately. The countdown is driven by the app's timer, but the deadline it shows belongs to the helper, so quitting the app does not extend it.
+- **User installs.** A standard user can install an application an administrator has allowlisted, without holding administrator rights for it. Support Companion registers for `.pkg` and `.dmg` files, so a double-click opens a sheet that names the installer, what signed it, what verified it, and where it would write, then installs it through the helper. Finder's context menu also offers **Install with Support Companion**.
+
+ This exists because the alternative is worse. The usual answer to "I need to install this" is a window of full administrator rights, during which the user can do everything else administrator rights allow, and only the reason they typed records what it was for. Here the user gets one install of one approved thing, and nothing else.
+
+ **The helper decides, not the app.** It re-reads the policy and re-derives the installer's facts before it acts, so the sheet is presentation only. As with the other privileged settings, the policy is read **only from a configuration profile** — a device-scoped one, or one scoped to the user being served.
+
+```xml
+EnableUserInstalls
+
+AllowedInstallers
+
+
+ Name
+ Firefox
+ TeamID
+ 43AQ936H96
+ BundleIdentifier
+ org.mozilla.firefox
+ MinimumVersion
+ 128.0
+
+
+```
+
+ Each entry needs a `Name`, which is what the sheet and the log call it, and enough to identify one thing: a `TeamID` plus a `PackageIdentifier` or `BundleIdentifier`, or a `SHA256` of the installer file. **An entry that cannot decide anything is dropped and logged, not read permissively** — the safe reading of a half-written entry is that nothing was allowed. Per entry:
+
+ - `TeamID` — the Developer ID team the installer must be signed by. Required unless `SHA256` is set.
+ - `PackageIdentifier`, `BundleIdentifier` — the identifier to accept, as a string or an array. For a `.dmg` this is the app inside it.
+ - A distribution package must have **every** component covered, not merely one. Accepting it because one component was named would let the others in unexamined, which is how an approved application arrives with a LaunchDaemon nobody asked about. The refusal names the component that was not covered.
+ - `AllowAnyIdentifier` — accept anything that team signs. This is a vendor allowlist, not an app: it covers every installer they will ever sign. It has its own key so that leaving an identifier out of a profile by mistake cannot produce it.
+ - `SHA256` — the exact digest of the installer file. Pins one build, and is the only form that needs no signature at all.
+ - `LeafCertificateSHA256` — pins the signing certificate itself, which is unambiguous in a way a team name is not, at the cost of needing an update when the vendor renews. Never required.
+ - `MinimumVersion` — refuse anything older, so a signed-but-vulnerable build cannot be installed instead of the current one. Compared numerically, not as text.
+ - `RequireNotarized` — require Apple notarization as well as a signature.
+ - `AllowScripts` — **off by default, and the most useful restriction here.** A scriptless package whose payload lands in `/Applications` is a file copy; one with a postinstall script is arbitrary code as root on every future build that vendor signs.
+ - `AllowedPayloadPrefixes` — the absolute path prefixes this installer may write to. Unrestricted by default, because enumerating paths for every app is more than most administrators will do, but setting it is what bounds the damage when an entry is written loosely: an allowlisted package is otherwise free to drop a LaunchDaemon while installing the app that was approved.
+ - `AllowUnrestrictedPayload` — let it write anywhere, including the paths held back below. Its own key, so the most dangerous setting is one somebody has to mean rather than one they can reach by typing a prefix wrong.
+
+ **Some destinations are held back unless `AllowedPayloadPrefixes` names them**, whatever signed the package: `/Library/Managed Preferences` and `/Library/Preferences`, this app's own preferences and its helper's state directory, `/Library/PrivilegedHelperTools`, `/Library/Security`, `/Library/ScriptingAdditions`, `/var/db/dslocal`, `/etc/sudoers` and `sudoers.d`, `/etc/pam.d`, `/etc/ssh`, and `/var/root`. These are the paths that decide what may be installed and who may hold administrator rights, so an install that writes there is how this feature would be turned against itself.
+
+ **This is a policy, not a sandbox.** An allowlisted installer runs as root, and `AllowScripts` with a broad `AllowedPayloadPrefixes` is close to the elevation it replaces. The restrictions are worth setting; what they buy is that an administrator chose the software, not that the software is harmless.
+
+- `RequireAuthenticationForInstalls`, on by default, which asks the user to authenticate before an allowlisted install proceeds. Only honored from a configuration profile.
+- `UserInstallFallback`, which decides what a user is offered when an installer is **not** allowlisted: `installer` (the default) hands the file to Installer.app or Finder exactly as a double-click would without this app, `elevate` offers the existing time-limited elevation flow, and `none` offers nothing. The sheet names why it was refused either way, in terms an administrator can act on.
+- `ShowInstallerServiceMenuItem`, which controls Finder's **Install with Support Companion** item. Follows `EnableUserInstalls` unless set, so an organisation that does not use the feature never sees it, and one that does can still hide it to keep the only route inside the app.
+- **Fleet mode.** Support Companion now integrates with [Fleet](https://fleetdm.com), using the Fleet device API with the device's own token, so no API keys are needed. Fleet mode is used when Fleet's agent (orbit) is installed, when the MDM server is the Fleet server, or when `Mode` is set to `Fleet`. The Fleet server URL is read from fleetd's configuration profile or orbit's LaunchDaemon. It can be overridden with `FleetUrl`, which is only honored when set by a configuration profile, since the device token is sent to that server. Example configuration:
+```xml
+Mode
+Fleet
+```
+- **In-app Fleet Desktop SSO sign-in.** Where Fleet requires SSO for the device page (Fleet 4.92 and later), signing in happens in the app: Fleet hands back the identity provider's URL, the user signs in to it in a web view, and the app keeps the session Fleet returns in the keychain. The SAML assertion stays between the user and their identity provider — the app only ever sees the resulting session cookie. Sessions last as long as Fleet's `session.duration` (five days by default) and expire absolutely rather than on idle, so the sheet reappears when it lapses.
+
+ **Signed out, the app does not pretend to know nothing.** Fleet's device summary endpoint is outside the SSO gate and reports the failing-policy count, so the sidebar badge, the Dock badge, the compliance banner and the compliance card stay truthful, and the Fleet and compliance cards collapse to a single Sign In action instead of a wall of Unknown. Everything identifying — host ID, team, last seen, policy names, the self-service catalog — is withheld by Fleet until sign-in, and that is not something this app can work around. `supportcompanion://fleetsignin` opens the sheet.
+- `FleetNotifySignIn`, a notification asking the user to sign in to Fleet, leading with the number of failing checks when there are any. **Off by default**, unlike the other `FleetNotify*` keys: it asks the user to do something rather than telling them something, so it is not imposed. Sent at most once a day, and reset by a successful sign-in.
+```xml
+FleetNotifySignIn
+
+```
+- **Self-service apps.** In Fleet mode the Apps page shows the device's Fleet self-service software, with search, category filters and collapsible Updates Available, Available and Installed sections. Apps can be installed, updated, reinstalled and uninstalled. Progress is followed until Fleet reports the result, and the output of failed installs can be viewed from the app card. Updates stay listed until the new version is actually installed.
+- Custom button text per app, for example "Request" for an app named "Request software". Keys are the software title ID, display name or name; values are either a string that replaces the Install label, or a dictionary with `Install`, `Update`, `Reinstall` and `Uninstall`. Example configuration:
+```xml
+FleetButtonLabels
+
+ Request software
+ Request
+ 42
+
+ Install
+ Get
+ Uninstall
+ Remove
+
+
+```
+- A highlighted Recommended section at the top of the Apps page, listing apps IT recommends in the configured order. Entries are title IDs or names, and the section title can be changed. Example configuration:
+```xml
+FleetRecommendedApps
+
+ Slack
+ 42
+
+FleetRecommendedTitle
+Start here
+```
+- App icons come from Fleet (custom and App Store icons), from the installed app, or from the icon set Fleet's own web pages use. That icon set is loaded from Fleet's GitHub repository and cached, and apps without an icon get a letter tile. To stop requests to GitHub:
+```xml
+FleetIconsFromGitHub
+
+```
+- **Updates blocked by an open app.** When an install doesn't run because the app is open, the app card shows "Waiting for app to close" instead of "Install failed", with a **Quit & Update** button that quits the app gracefully (it can still prompt to save) and installs again. This works for Fleet-maintained apps with patch when closed, and for custom packages whose install script prints a message about the app being open. The built-in phrases can be replaced with your scripts' exact messages; an empty array turns detection off for custom packages. Example configuration:
+```xml
+FleetAppOpenMessages
+
+ Please close Chrome before updating
+
+```
+- **Home cards.** In Fleet mode the patching progress and pending updates cards use Fleet's self-service updates, and the Apps sidebar item shows the number of updates.
+- A **Device Compliance** card listing failing Fleet policies with their resolution text, critical ones first, and a collapsible list of passing checks. Its **Re-check** button asks Fleet to refresh the device's details and re-run its policies. While any check fails, a banner at the top of Home shows what needs attention. The card can be hidden using `HiddenCards`, which also hides the banner, badges and policy notifications:
+```xml
+HiddenCards
+
+ FleetPolicies
+
+```
+- A **Fleet** card showing the device's Fleet host ID, team, last check-in, last inventory update and server. It can be hidden using `HiddenCards`:
+```xml
+HiddenCards
+
+ Fleet
+
+```
+- The tray menu shows compact Device Compliance and Fleet cards in Fleet mode. The compliance card has Re-check and Details buttons.
+- **Fleet notifications**, each on by default:
+ - When an install, update, reinstall or uninstall started from Support Companion finishes or fails, or needs the app to be closed (with a Quit & Update button). Turn off with `FleetNotifyInstallResults`.
+ - When updates are available, listing the apps, with an **Update Now** button that installs them. Clicking the notification opens the Apps page. Uses `AppUpdateNotificationMessage`, `AppUpdateNotificationButtonText` and `NotificationInterval`. Turn off with `FleetNotifyUpdates`.
+ - When a policy starts failing, sent once per failure. Turn off with `FleetNotifyPolicies`.
+```xml
+FleetNotifyInstallResults
+
+FleetNotifyUpdates
+
+FleetNotifyPolicies
+
+```
+- Failing compliance checks count toward the tray menu icon's badge and the Dock badge, and are shown as a badge on the Home sidebar item.
+- Support for Background Security Improvements. If the pending update is a background security improvement, clicking the update will open the relevant pane in system settings.
+- WiFi SSID information is now included in the device information
+- New option to hide tray menu icon. This allows for using the desktop information window without displaying the tray menu icon. Example configuration:
+```xml
+TrayMenuShowIcon
+
+```
+- Support for monitoring Jamf application patches. When in Jamf mode, the app will now monitor for pending application patches and display them in the tray menu as well as in the main app. The badge will also be displayed in the tray menu icon when there are pending application patches. Requires the use of Self Service+.
+ - Correctly monitoring application patches from Self Service+ requires that Self Service+ is configured for SSO and that `Enable Self Service user login` is **not** checked in the Self Service configuration in Jamf Pro. This is because the data in the app is lazy updated when the user starts and authenticates in Self Service+. To work around this, Support Companion will briefly launch Self Service+ in the background to update the patch data. An icon will appear in the dock while this is happening. This process should only take a few seconds.
+ - Can be turned off by setting `RefreshSelfService` to `false` in the configuration. Example configuration:
+```xml
+RefreshSelfService
+
+```
+- A new default card for Jamf mode that displays the last time the device checked in, the last inventory time and the MDM enrollment time as well as the ID of the device in Jamf. This card is only displayed when in Jamf mode and can be hidden using the `HiddenCards` configuration.
+```xml
+HiddenCards
+
+ Jamf
+
+```
+- A new option for Jamf mode to set the polling interval for logs collection to gather last check in time and last inventory time. This allows for admins to set how often the app should check for new log data. By default, the interval is set to 36 hours. Example configuration:
+```xml
+JamfLogPollHours
+46
+```
+- Logging will now be done in a log file located at `~/Library/Logs/SupportCompanion/SupportCompanion.log` in addition to os log. This allows for easier troubleshooting of issues with the app. The log file will be rotated when it reaches 5 MB in size. Debug logging can be enabled by setting `DebugLogging` to `true` in the configuration. Example configuration:
+```xml
+DebugLogging
+
+```
+
+### Changed
+- Pending updates count is now shown as a badge on the sidebar navigation item, making it visible without opening the updates view.
+- Accessibility labels added to icon-only buttons for improved VoiceOver support.
+- Significant internal code quality improvements: preferences split into focused sub-objects, helpers refactored into dedicated files, Timer-based polling migrated to Swift structured concurrency, and improved error handling with logging throughout.
+- If `BrandName` is configured, it will now be displayed in the desktop information window as well as the header instead of "Device Information".
+- A new localized message will be displayed in the applications view stating that the apps are installed by the `mode`. This is to clarify the view only displays apps installed by the MDM and not all apps installed on the device.
+- User info will now use OpenDirectory to gather user information instead of `finger` command.
+- A delay has been added to `InfoHelp` when hovering over the info icon to prevent accidental triggering of the help popup.
+- App update names line limit has been increased to `2` lines to prevent truncation of long app names.
+- Add support for a custom Company Portal URL (e.g. GCC High / sovereign cloud endpoints) and harden the Intune MDM detection logic so it correctly identifies Intune across all manage.microsoft.* domains while avoiding obvious false positives. Thanks @Actu4l-Human.
+- Clicking a notification about apps now opens the relevant page in Support Companion, handled by the running app.
+- The Dock badge updates as soon as the number of pending items changes, and no longer counts items whose card or button is hidden.
+- Much lower resource use on the Home page: the patch progress wave is drawn with Core Animation. With Home open, memory use went from about 140 MB to about 55 MB, and CPU use from 30–40% to about 0%. Reduce Motion still stops the animation.
+- Views only update when the data they show changes, and preference changes from a configuration profile or `defaults write` show up right away.
+- The battery card's Time to Full shows Fully Charged, Not Charging or Calculating… instead of N/A while on external power. The temperature row is hidden when no reading is available, and copied device info uses the system's temperature unit.
+- The tray menu popover closes when clicking outside it, pressing Escape, or opening the main window.
+
+### Fixed
+- **Opening a web page in the sidebar — Knowledge Base or Company Portal — could crash the app.** The embedded web view was sized from its own content, and the page reflowed to whatever size it was given, so each layout pass changed the size that drove the next one. SwiftUI reported the resulting dependency cycles and eventually crashed laying out the view. The web view now takes the size it is offered. Its navigation delegate was also being replaced by one that updated loading state synchronously, which could invalidate the view while it was being evaluated; the web view's own delegate, which defers those updates, is left in place.
+- The CLI built its `supportcompanion://run` URL by interpolation, so action names containing `&`, `#` or spaces were misrouted. It now uses `URLComponents`.
+- Removed an undefined variable from the package's postinstall script, and balanced a quote in the helper's linker flags that absorbed the following flag.
+- File watcher would not correctly detect changes on custom JSON cards if the file was replaced instead of modified. This has been fixed by using a different method to monitor file changes.
+- The pending updates badge on `Software Updates` was transparent in the main app.
+- `FileVault` did not hide the item on the desktop information window when configured to be hidden.
+- The MDM enrollment date is now found by the MDM payload instead of the profile name, so it works for MDMs other than Jamf and Intune instead of failing.
+- Mode detection now compares the MDM server's host correctly. The MDM URL is read without its scheme, so the host comparison never ran before.
+- A notification without a button could remove the button from earlier notifications still in Notification Center.
+- On macOS 27 the tray menu popover closed a few seconds after opening, for example in Jamf mode while Self Service+ was refreshed in the background.
+- On macOS 27 the battery card showed 0% health and 0.0 °C.
+- Commands that print a lot of output, such as gathering logs over a long period, could hang.
+- Commands and actions containing single quotes didn't run correctly.
+- Jamf patches ran as the user with UID 504 instead of the logged-in user.
+- Mode detection stopped before choosing a mode when the MDM server was Intune or Jamf, so the mode was never set on those Macs.
+- On non-English systems, hiding the Battery or Evergreen card with `HiddenCards` didn't stop their background refresh.
+- The "Updating…" label for Jamf patches didn't update.
+- Web views could be created twice for the same tab, and monitoring (for example battery) kept running after the main window was closed.
+- Swedish and French restart countdown translations.
+
+### Notes for Fleet mode
+- Requires Fleet's agent (orbit) on the device. Features follow what the Fleet server supports; the Fleet flag for skipped patch-when-closed installs (`skipped_install`) is newer than Fleet 4.91, and older servers are handled through the install output instead.
+- Fleet Desktop single sign-on isn't supported yet. It hasn't shipped in a Fleet release.
+
## [2.3.1] - 2025-10-06
### Changed
- Refactored the uninstall script with better error handling and logging.
diff --git a/DDM/make_ddm_assets.zsh b/DDM/make_ddm_assets.zsh
new file mode 100755
index 0000000..34aedb5
--- /dev/null
+++ b/DDM/make_ddm_assets.zsh
@@ -0,0 +1,147 @@
+#!/bin/zsh
+#
+# Build the two assets needed to deploy the privileged helper with
+# com.apple.configuration.services.background-tasks.
+#
+# Usage: ./make_ddm_assets.zsh [TaskType] [output dir]
+#
+# Produces, in the output directory:
+# helper.zip the executable asset (ExecutableAssetReference)
+# .plist the launchd job (LaunchdConfigurations → FileAssetReference)
+# declarations.json the three declarations, with hashes and sizes filled in
+#
+# Settings, including EnforceAdminAllowlist and PermanentAdmins, come from the ordinary
+# configuration profile, not from here.
+#
+# Host helper.zip and the plist over https, put their URLs into declarations.json, and load the
+# declarations into your MDM.
+
+set -e
+
+APP="${1:?Usage: make_ddm_assets.zsh [TaskType] [output dir]}"
+TASK_TYPE="${2:-com.github.macadmins.SupportCompanion}"
+OUT="${3:-./ddm-assets}"
+
+LABEL="com.github.macadmins.SupportCompanion.helper"
+HELPER_SRC="${APP}/Contents/Library/LaunchDaemons/${LABEL}"
+
+# The background-tasks configuration extracts the zip into this directory, so the launchd job has to
+# point at the executable inside it rather than at /Library/PrivilegedHelperTools.
+MANAGED_DIR="/var/db/ManagedConfigurationFiles/BackgroundTaskServices/Services/${TASK_TYPE}"
+
+if [[ ! -f "$HELPER_SRC" ]]; then
+ print -u2 "Helper executable not found at $HELPER_SRC"
+ exit 1
+fi
+
+/bin/rm -rf "$OUT"
+/bin/mkdir -p "$OUT/staging"
+
+/bin/cp "$HELPER_SRC" "$OUT/staging/${LABEL}"
+/bin/chmod 544 "$OUT/staging/${LABEL}"
+
+
+# Keep the signature intact: the helper must still validate, and -X keeps the zip free of the
+# resource-fork and finder-info entries that would otherwise be added.
+( cd "$OUT/staging" && /usr/bin/zip -qrX "../helper.zip" "${LABEL}" )
+/bin/rm -rf "$OUT/staging"
+
+/bin/cat > "$OUT/${LABEL}.plist" <
+
+
+
+ Label
+ ${LABEL}
+ ProgramArguments
+
+ ${MANAGED_DIR}/${LABEL}
+
+ MachServices
+
+ ${LABEL}
+
+
+ RunAtLoad
+
+ Disabled
+
+
+
+
+PLIST
+
+/usr/bin/plutil -lint "$OUT/${LABEL}.plist" > /dev/null
+
+hash_of() { /usr/bin/shasum -a 256 "$1" | /usr/bin/awk '{print $1}' }
+size_of() { /usr/bin/stat -f%z "$1" }
+
+ZIP_HASH=$(hash_of "$OUT/helper.zip")
+ZIP_SIZE=$(size_of "$OUT/helper.zip")
+PLIST_HASH=$(hash_of "$OUT/${LABEL}.plist")
+PLIST_SIZE=$(size_of "$OUT/${LABEL}.plist")
+
+/bin/cat > "$OUT/declarations.json" < uid_t {
+ #if DEBUG
+ Logger.shared.logDebug("🔍 Starting connection validation...")
+ #endif
+
+ let auditToken = try auditToken(in: connection)
+ let tokenData = withUnsafeBytes(of: auditToken) { Data($0) }
+
+ #if DEBUG
+ Logger.shared.logDebug("✅ Got audit token data")
+ #endif
+
let secCode = try secCode(from: tokenData)
- try? logInfo(about: secCode)
+
+ #if DEBUG
+ Logger.shared.logDebug("✅ Got SecCode from token")
+ Logger.shared.logDebug("🔐 Verifying code signature...")
+ #endif
+
+ // Signature first: nothing read out of the client's signing information means anything
+ // until the signature over it has been checked.
try verifySecCode(secCode: secCode)
+
+ try? logInfo(about: secCode)
+
+ try verifyClientPolicy(secCode: secCode)
+
+ #if DEBUG
+ Logger.shared.logDebug("✅ Connection validated successfully!")
+ #endif
+
+ return audit_token_to_euid(auditToken)
}
}
@@ -49,7 +78,7 @@ extension ConnectionIdentityService {
/// Get the property `auditToken` from a `NSXPCConnection`.
///
/// - note: This is a hack, see [Woody's Findings](https://www.woodys-findings.com/posts/cocoa-implement-privileged-helper).
- private static func tokenData(in connection: NSXPCConnection) throws -> Data {
+ private static func auditToken(in connection: NSXPCConnection) throws -> audit_token_t {
let property = "auditToken"
guard connection.responds(to: NSSelectorFromString(property)) else {
@@ -61,11 +90,11 @@ extension ConnectionIdentityService {
guard let auditTokenValue = auditToken as? NSValue else {
throw SupportCompanionErrors.helperConnection("Unable to get 'NSValue' from '\(property)' in 'NSXPCConnection'")
}
- guard var auditTokenOpaque = auditTokenValue.value(of: audit_token_t.self) else {
+ guard let auditTokenOpaque = auditTokenValue.value(of: audit_token_t.self) else {
throw SupportCompanionErrors.helperConnection("'\(property)' 'NSValue' is not of type 'audit_token_t'")
}
- return Data(bytes: &auditTokenOpaque, count: MemoryLayout.size)
+ return auditTokenOpaque
}
}
@@ -89,11 +118,59 @@ extension ConnectionIdentityService {
private static func verifySecCode(secCode: SecCode) throws {
var secRequirements: SecRequirement?
+
+ // Log the expected requirement for debugging
+ #if DEBUG
+ Logger.shared.logDebug("📋 Expected requirement string: \(requirementString)")
+ #endif
- try SecRequirementCreateWithString(requirementString, [], &secRequirements)
- .checkError("SecRequirementCreateWithString")
- try SecCodeCheckValidity(secCode, [], secRequirements)
- .checkError("SecCodeCheckValidity")
+ let createStatus = SecRequirementCreateWithString(requirementString, [], &secRequirements)
+
+ #if DEBUG
+ if createStatus != errSecSuccess {
+ Logger.shared.logError("❌ Failed to create requirement from string. Status: \(createStatus)")
+ } else {
+ Logger.shared.logDebug("✅ Requirement created successfully")
+ }
+ #endif
+
+ try createStatus.checkError("SecRequirementCreateWithString")
+
+ // Log the actual requirement for debugging
+ #if DEBUG
+ do {
+ var secStaticCode: SecStaticCode?
+ try SecCodeCopyStaticCode(secCode, [], &secStaticCode).checkError("SecCodeCopyStaticCode for requirement")
+
+ if let staticCode = secStaticCode {
+ var actualRequirement: SecRequirement?
+ let status = SecCodeCopyDesignatedRequirement(staticCode, [], &actualRequirement)
+ if status == errSecSuccess, let req = actualRequirement {
+ var reqString: CFString?
+ SecRequirementCopyString(req, [], &reqString)
+ if let str = reqString as String? {
+ Logger.shared.logDebug("📋 Actual designated requirement from app: \(str)")
+ }
+ } else {
+ Logger.shared.logDebug("⚠️ Could not get designated requirement. Status: \(status)")
+ }
+ }
+ } catch {
+ Logger.shared.logDebug("⚠️ Could not get actual requirement: \(error)")
+ }
+ #endif
+
+ let validityStatus = SecCodeCheckValidity(secCode, [], secRequirements)
+
+ #if DEBUG
+ if validityStatus != errSecSuccess {
+ Logger.shared.logError("❌ SecCodeCheckValidity failed. Status: \(validityStatus)")
+ } else {
+ Logger.shared.logDebug("✅ Code signature validation passed")
+ }
+ #endif
+
+ try validityStatus.checkError("SecCodeCheckValidity")
}
private static func logInfo(about secCode: SecCode) throws {
@@ -119,3 +196,124 @@ extension ConnectionIdentityService {
Logger.shared.logDebug("Received connection request from app with bundle ID '\(bundleID)'")
}
}
+
+// MARK: - Client policy
+
+extension ConnectionIdentityService {
+
+ /// Check the connecting client against the policy the signature cannot express.
+ ///
+ /// A valid signature only proves the caller is *a* Support Companion build signed by us. Every
+ /// release we have ever shipped satisfies it, including builds that predate the administrator-managed
+ /// gate on `IsPrivileged`, so on its own it lets a user keep an old copy around and use it to reach
+ /// this helper. These three checks close that gap:
+ ///
+ /// - **Version floor.** Builds older than `HelperConstants.minimumClientVersion` honor `IsPrivileged`
+ /// from any preference domain, including one a standard user can write with `defaults write`.
+ /// - **Hardened runtime.** Without it, a legitimate client can be injected into and used as a proxy.
+ /// - **Install path.** `/Applications` is not writable by a standard user, so a downgraded copy run
+ /// from a home folder is rejected before the version even matters. Skipped in debug builds, which
+ /// run out of DerivedData.
+ private static func verifyClientPolicy(secCode: SecCode) throws {
+ let info = try signingInformation(about: secCode)
+
+ try verifyClientVersion(info: info)
+ try verifyHardenedRuntime(info: info)
+
+ #if !DEBUG
+ try verifyClientPath(info: info)
+ #endif
+
+ #if DEBUG
+ Logger.shared.logDebug("✅ Client policy checks passed")
+ #endif
+ }
+
+ private static func verifyClientVersion(info: NSDictionary) throws {
+ guard let plist = info["info-plist"] as? NSDictionary else {
+ throw SupportCompanionErrors.helperConnection("Unable to read the client's Info.plist to check its version")
+ }
+
+ let rawVersion = plist["CFBundleShortVersionString"] as? String
+ ?? plist["CFBundleVersion"] as? String
+
+ guard let rawVersion else {
+ throw SupportCompanionErrors.helperConnection("Client reports no version")
+ }
+
+ let minimum = HelperConstants.minimumClientVersion
+
+ // `a >= b` for version components, which Array does not provide directly
+ let isOlder = versionComponents(rawVersion).lexicographicallyPrecedes(versionComponents(minimum))
+
+ guard !isOlder else {
+ throw SupportCompanionErrors.helperConnection(
+ "Client version \(rawVersion) is older than the minimum supported version \(minimum)"
+ )
+ }
+ }
+
+ private static func verifyHardenedRuntime(info: NSDictionary) throws {
+ guard let rawFlags = (info[kSecCodeInfoFlags as String] as? NSNumber)?.uint32Value else {
+ throw SupportCompanionErrors.helperConnection("Unable to read the client's code signing flags")
+ }
+
+ guard SecCodeSignatureFlags(rawValue: rawFlags).contains(.runtime) else {
+ throw SupportCompanionErrors.helperConnection("Client is not signed with the hardened runtime")
+ }
+ }
+
+ private static func verifyClientPath(info: NSDictionary) throws {
+ let executablePath: String?
+
+ if let url = info[kSecCodeInfoMainExecutable as String] as? URL {
+ executablePath = url.path
+ } else {
+ executablePath = info[kSecCodeInfoMainExecutable as String] as? String
+ }
+
+ guard let executablePath else {
+ throw SupportCompanionErrors.helperConnection("Unable to read the client's executable path")
+ }
+
+ guard executablePath.hasPrefix(HelperConstants.appPath + "/") else {
+ throw SupportCompanionErrors.helperConnection(
+ "Client runs from \(executablePath) rather than \(HelperConstants.appPath)"
+ )
+ }
+ }
+
+ /// Leading numeric components of a version string, so that the four-component build version
+ /// the release script writes ("3.0.0.81132") compares correctly against a three-component minimum.
+ private static func versionComponents(_ version: String) -> [Int] {
+ var components: [Int] = []
+
+ for part in version.split(separator: ".") {
+ guard let number = Int(part) else { break }
+ components.append(number)
+ }
+
+ return components
+ }
+
+ private static func signingInformation(about secCode: SecCode) throws -> NSDictionary {
+ var secStaticCode: SecStaticCode?
+ var cfDictionary: CFDictionary?
+
+ try SecCodeCopyStaticCode(secCode, [], &secStaticCode)
+ .checkError("SecCodeCopyStaticCode")
+
+ guard let secStaticCode else {
+ throw SupportCompanionErrors.helperConnection("Unable to get a 'SecStaticCode' from 'SecCode'")
+ }
+
+ try SecCodeCopySigningInformation(secStaticCode, [], &cfDictionary)
+ .checkError("SecCodeCopySigningInformation")
+
+ guard let dictionary = cfDictionary as NSDictionary? else {
+ throw SupportCompanionErrors.helperConnection("Unable to read the client's signing information")
+ }
+
+ return dictionary
+ }
+}
diff --git a/Helper/DiskImageInspector.swift b/Helper/DiskImageInspector.swift
new file mode 100644
index 0000000..7dc6df5
--- /dev/null
+++ b/Helper/DiskImageInspector.swift
@@ -0,0 +1,352 @@
+//
+// DiskImageInspector.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import CryptoKit
+import Foundation
+import Security
+
+/// Reads what a disk image contains, with the image mounted where only root can reach it.
+///
+/// A drag-install application needs no scripts and runs nothing as root — the install is a copy — but
+/// it still needs the helper, because `/Applications` is `root:admin` and a standard user cannot write
+/// to it. That makes this the safer half of the feature: what is checked and what is copied are the
+/// same bytes, and nothing of the vendor's ever executes with privileges.
+enum DiskImageInspector {
+
+ // MARK: Mounting
+
+ struct Mount {
+ let mountPoint: String
+ let deviceEntry: String
+ }
+
+ /// Attach the image underneath the staging directory.
+ ///
+ /// `-mountrandom` into our own root-owned, `0700` directory rather than `/Volumes`: mounted in
+ /// `/Volumes` the user could read the image while it is being checked and, more to the point,
+ /// replace what is at that path between the check and the copy.
+ static func attach(imageAt path: String, under directory: String) async throws -> Mount {
+ let mountRoot = (directory as NSString).appendingPathComponent("mnt")
+ try HelperState.makeDirectory(at: mountRoot)
+
+ // No stdin is attached to the helper, so an image with a licence agreement fails here rather
+ // than waiting for an answer that cannot arrive. Those have to go through Installer.app.
+ let output = try await ExecutionService.run("/usr/bin/hdiutil", [
+ "attach", path,
+ "-nobrowse", "-readonly", "-noautoopen", "-owners", "off",
+ "-mountrandom", mountRoot,
+ "-plist"
+ ])
+
+ guard
+ let data = output.data(using: .utf8),
+ let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any],
+ let entities = plist["system-entities"] as? [[String: Any]]
+ else {
+ throw SupportCompanionErrors.helperConnection("Unable to read what mounting the disk image produced")
+ }
+
+ guard let mounted = entities.first(where: { $0["mount-point"] is String }),
+ let mountPoint = mounted["mount-point"] as? String
+ else {
+ throw SupportCompanionErrors.helperConnection("The disk image mounted nothing that can be read")
+ }
+
+ // Detaching wants the whole image's device, which is the shortest of the entries.
+ let deviceEntry = entities
+ .compactMap { $0["dev-entry"] as? String }
+ .min(by: { $0.count < $1.count })
+ ?? (mounted["dev-entry"] as? String ?? mountPoint)
+
+ return Mount(mountPoint: mountPoint, deviceEntry: deviceEntry)
+ }
+
+ static func detach(_ mount: Mount) async {
+ do {
+ _ = try await ExecutionService.run("/usr/bin/hdiutil", ["detach", mount.deviceEntry, "-quiet"])
+ } catch {
+ Logger.shared.logError("Unable to detach \(mount.deviceEntry): \(error.localizedDescription). Forcing.")
+ _ = try? await ExecutionService.run("/usr/bin/hdiutil", ["detach", mount.deviceEntry, "-force", "-quiet"])
+ }
+ }
+
+ // MARK: Contents
+
+ enum Payload {
+ case application(path: String)
+ case package(path: String)
+ }
+
+ /// Find the one thing in the image that can be installed.
+ ///
+ /// Exactly one, and only at the top level. An image holding two applications, or one tucked inside
+ /// a folder, is refused rather than guessed at: the guess would decide what gets installed as root.
+ static func payload(in mountPoint: String) throws -> Payload {
+ let entries = try FileManager.default.contentsOfDirectory(atPath: mountPoint)
+ .filter { !$0.hasPrefix(".") }
+
+ let applications = try entries
+ .filter { ($0 as NSString).pathExtension.lowercased() == "app" }
+ .map { try validatedEntry($0, in: mountPoint) }
+
+ let packages = try entries
+ .filter { ["pkg", "mpkg"].contains(($0 as NSString).pathExtension.lowercased()) }
+ .map { try validatedEntry($0, in: mountPoint) }
+
+ if applications.count == 1 && packages.isEmpty {
+ return .application(path: applications[0])
+ }
+
+ if packages.count == 1 && applications.isEmpty {
+ return .package(path: packages[0])
+ }
+
+ if applications.isEmpty && packages.isEmpty {
+ throw SupportCompanionErrors.helperConnection("The disk image contains no application or installer package")
+ }
+
+ throw SupportCompanionErrors.helperConnection(
+ "The disk image contains more than one installable item, so there is no way to tell which one was meant"
+ )
+ }
+
+ /// Refuse a payload that is a symbolic link, or that resolves outside the image.
+ ///
+ /// A disk image is authored by whoever handed it over, and a link in it is resolved at the moment
+ /// it is used — by the helper, as root. An image containing `Vendor.pkg -> ~/their.pkg` would have
+ /// the allowlist checked against a file the user can replace afterwards, which is the whole
+ /// guarantee of this feature turned inside out. The image's own digest says nothing about it,
+ /// because the image genuinely is the one that was approved.
+ ///
+ /// Every drag-install image contains an `Applications` symlink, so links are ordinary here; it is
+ /// only a link *as the payload* that is refused.
+ private static func validatedEntry(_ name: String, in mountPoint: String) throws -> String {
+ let path = (mountPoint as NSString).appendingPathComponent(name)
+
+ var info = stat()
+
+ guard lstat(path, &info) == 0 else {
+ throw SupportCompanionErrors.helperConnection("Unable to read '\(name)' in the disk image")
+ }
+
+ guard (info.st_mode & S_IFMT) != S_IFLNK else {
+ Logger.shared.logError("Refusing \(path): the payload is a symbolic link")
+ throw SupportCompanionErrors.helperConnection("'\(name)' is a link rather than something this can install")
+ }
+
+ // Belt and braces for any other way out of the mount, such as a firmlink.
+ let resolved = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
+ let root = URL(fileURLWithPath: mountPoint).resolvingSymlinksInPath().path
+
+ guard resolved == root || resolved.hasPrefix(root.hasSuffix("/") ? root : root + "/") else {
+ Logger.shared.logError("Refusing \(path): it resolves to \(resolved), outside the disk image")
+ throw SupportCompanionErrors.helperConnection("'\(name)' points outside the disk image")
+ }
+
+ return path
+ }
+
+ /// Copy the payload out of the mounted image into root-owned staging.
+ ///
+ /// So that the image can be detached before anything is installed, and so that what was inspected
+ /// and what gets installed are one set of bytes in a directory only root can write. While the
+ /// payload is still inside the image it is only as stable as the image's author allows.
+ static func copyOut(_ payload: Payload, into directory: String) async throws -> Payload {
+ let destinationDirectory = (directory as NSString).appendingPathComponent("payload")
+ try HelperState.makeDirectory(at: destinationDirectory)
+
+ let source: String
+ switch payload {
+ case .application(let path): source = path
+ case .package(let path): source = path
+ }
+
+ let destination = (destinationDirectory as NSString)
+ .appendingPathComponent((source as NSString).lastPathComponent)
+
+ // `ditto` keeps the bundle intact, including the extended attributes a signature depends on.
+ _ = try await ExecutionService.run("/usr/bin/ditto", ["--noqtn", source, destination])
+
+ switch payload {
+ case .application: return .application(path: destination)
+ case .package: return .package(path: destination)
+ }
+ }
+
+ // MARK: Application signature
+
+ struct ApplicationSignature {
+ var trusted = false
+ var notarized = false
+ var teamID: String?
+ var bundleIdentifier: String?
+ var authority: String?
+ var leafCertificateSHA256: String?
+ }
+
+ /// Verify an application bundle's signature and read who signed it.
+ ///
+ /// `anchor apple generic` establishes that the signature chains to Apple; the Team ID and the
+ /// bundle identifier are then read from the signature itself rather than from `Info.plist`, which
+ /// is not evidence of anything — a bundle can claim any identifier it likes in a file, but only the
+ /// one it was signed with survives `SecStaticCodeCheckValidity`.
+ static func signature(ofApplicationAt path: String) async -> ApplicationSignature {
+ var signature = ApplicationSignature()
+
+ var staticCode: SecStaticCode?
+
+ guard SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &staticCode) == errSecSuccess,
+ let staticCode
+ else {
+ Logger.shared.logError("Unable to read a code signature from \(path)")
+ return signature
+ }
+
+ var requirement: SecRequirement?
+
+ guard SecRequirementCreateWithString("anchor apple generic" as CFString, [], &requirement) == errSecSuccess else {
+ return signature
+ }
+
+ // Every architecture, and the nested code too: a bundle can be signed correctly at the top
+ // level while carrying a helper or framework inside it that is not.
+ let flags = SecCSFlags(rawValue: kSecCSCheckAllArchitectures | kSecCSCheckNestedCode)
+ let validity = SecStaticCodeCheckValidity(staticCode, flags, requirement)
+
+ guard validity == errSecSuccess else {
+ Logger.shared.logError("\(path) failed signature validation with status \(validity)")
+ return signature
+ }
+
+ signature.trusted = true
+
+ var information: CFDictionary?
+
+ if SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
+ let info = information as? [String: Any] {
+ signature.teamID = info[kSecCodeInfoTeamIdentifier as String] as? String
+ signature.bundleIdentifier = info[kSecCodeInfoIdentifier as String] as? String
+
+ // Taken from the certificate itself rather than parsed out of a printed chain, which is
+ // what the package side has to settle for. Same value, same profile key.
+ if let certificates = info[kSecCodeInfoCertificates as String] as? [SecCertificate],
+ let leaf = certificates.first {
+ let der = SecCertificateCopyData(leaf) as Data
+ signature.leafCertificateSHA256 = SHA256.hash(data: der)
+ .map { String(format: "%02x", $0) }
+ .joined()
+ }
+ }
+
+ let assessment = await gatekeeperAssessment(of: path)
+ signature.notarized = assessment.notarized
+ signature.authority = assessment.origin
+
+ return signature
+ }
+
+ /// Read an application's signing identifier without requiring its signature to be valid.
+ ///
+ /// `signature(ofApplicationAt:)` answers "is this trustworthy" and gives up as soon as validation
+ /// fails, so it reports no identifier at all for an application that is unsigned, ad-hoc signed,
+ /// or has a broken nested signature. That is precisely the incumbent you want to identify before
+ /// replacing it. This asks only what the bundle says it is, and says so where it can.
+ ///
+ /// It is also cheap: no nested-code walk and no Gatekeeper fork, which on a large application is
+ /// seconds spent reading one string.
+ static func identifier(ofApplicationAt path: String) -> String? {
+ var staticCode: SecStaticCode?
+
+ if SecStaticCodeCreateWithPath(URL(fileURLWithPath: path) as CFURL, [], &staticCode) == errSecSuccess,
+ let staticCode {
+ var information: CFDictionary?
+
+ if SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &information) == errSecSuccess,
+ let info = information as? [String: Any],
+ let identifier = info[kSecCodeInfoIdentifier as String] as? String {
+ return identifier
+ }
+ }
+
+ // Unsigned entirely. Info.plist is not evidence of anything, but for deciding whether two
+ // bundles are the same application it is better than knowing nothing.
+ return Bundle(path: path)?.bundleIdentifier
+ }
+
+ /// Ask Gatekeeper to assess the application the way launching it would.
+ ///
+ /// As with packages, an assessment on a Mac where Gatekeeper has been turned off approves
+ /// everything, so a disabled assessment is reported as "not notarized" rather than as a pass.
+ private static func gatekeeperAssessment(of path: String) async -> (notarized: Bool, origin: String?) {
+ let status = try? await ProcessRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"),
+ arguments: ["--status"]
+ )
+
+ let statusOutput = (String(data: status?.output ?? Data(), encoding: .utf8) ?? "")
+ + (String(data: status?.error ?? Data(), encoding: .utf8) ?? "")
+
+ guard statusOutput.contains("assessments enabled") else {
+ Logger.shared.logError("Gatekeeper assessments are disabled on this Mac; treating \(path) as unassessed")
+ return (false, nil)
+ }
+
+ let result = try? await ProcessRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"),
+ arguments: ["--assess", "--type", "execute", "-vv", path]
+ )
+
+ guard let result else { return (false, nil) }
+
+ let output = (String(data: result.output, encoding: .utf8) ?? "")
+ + (String(data: result.error, encoding: .utf8) ?? "")
+
+ let lines = output.split(separator: "\n")
+ let source = lines.first { $0.hasPrefix("source=") }.map { String($0.dropFirst(7)) }
+ let origin = lines.first { $0.hasPrefix("origin=") }.map { String($0.dropFirst(7)) }
+
+ return (result.status == 0 && PackageInspector.isNotarized(source: source), origin)
+ }
+
+ // MARK: Facts
+
+ /// What the policy needs to know about an application inside a mounted image.
+ ///
+ /// The digest is of the disk image the user downloaded, not of the application inside it: that is
+ /// the file an administrator can hash to write a strict-mode entry.
+ static func facts(
+ forApplicationAt path: String,
+ imageDigest: String,
+ fileName: String
+ ) async throws -> InstallerFacts {
+ let signature = await signature(ofApplicationAt: path)
+
+ let bundle = Bundle(path: path)
+ let version = bundle?.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
+ ?? bundle?.object(forInfoDictionaryKey: "CFBundleVersion") as? String
+
+ let displayName = bundle?.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String
+ ?? bundle?.object(forInfoDictionaryKey: "CFBundleName") as? String
+ ?? (path as NSString).lastPathComponent.replacingOccurrences(of: ".app", with: "")
+
+ return InstallerFacts(
+ kind: .diskImage,
+ fileName: fileName,
+ sha256: imageDigest,
+ teamID: signature.teamID,
+ authority: signature.authority,
+ leafCertificateSHA256: signature.leafCertificateSHA256,
+ signatureTrusted: signature.trusted,
+ notarized: signature.notarized,
+ identifiers: [signature.bundleIdentifier].compactMap { $0 },
+ displayName: displayName,
+ version: version,
+ hasScripts: false,
+ hasRemoteReferences: false,
+ // A drag install writes one place, and `installApplication` will not write anywhere else.
+ payloadRoots: ["/Applications/\((path as NSString).lastPathComponent)"]
+ )
+ }
+}
diff --git a/Helper/ElevationCoordinator.swift b/Helper/ElevationCoordinator.swift
new file mode 100644
index 0000000..854c484
--- /dev/null
+++ b/Helper/ElevationCoordinator.swift
@@ -0,0 +1,670 @@
+//
+// ElevationCoordinator.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import Foundation
+
+/// Owns temporary administrator rights: who has them, until when, and taking them back.
+///
+/// The app used to run the demotion timer itself and keep the deadline in the user's own defaults, which
+/// made the time limit a suggestion — quitting the app, or deleting the key, left the user an administrator
+/// indefinitely. Both the deadline and the timer live here instead, in a root-owned file and in a process
+/// the user cannot stop, so the limit holds even if the app never runs again.
+///
+/// Several users can be elevated at once. With fast user switching two accounts can each be logged in and
+/// each ask for rights, and one window must not silently replace the other: whoever it discarded would keep
+/// administrator rights with nothing left to take them away.
+final class ElevationCoordinator: @unchecked Sendable {
+
+ static let shared = ElevationCoordinator()
+
+ // Not under /Library/Application Support: the app creates its folder there while running as the
+ // user, so that folder is owned by the user, and whoever owns a directory can replace or delete
+ // what is in it. A deadline the elevated user can rewrite is not a deadline. /var/db is root-owned
+ // and is where daemon state belongs.
+ private static let stateDirectory = "/var/db/com.github.macadmins.SupportCompanion"
+ private static let statePath = stateDirectory + "/elevation.plist"
+ private static let auditLogPath = stateDirectory + "/elevation.log"
+
+ /// How often the admin group is re-read while a window is open.
+ ///
+ /// One `dscl` read costs about 15ms, so this is a low single-digit percentage of one core while
+ /// somebody is elevated, and nothing at all the rest of the time.
+ private static let watchdogInterval: DispatchTimeInterval = .seconds(1)
+
+ /// How often the admin group is reconciled against the allowlist, independently of any elevation.
+ ///
+ /// The allowlist comes from a configuration profile, which a user with root can delete. Their MDM
+ /// puts it back, but nothing would re-read it until the helper restarted, so a reboot-free bypass
+ /// would otherwise stay open indefinitely. Re-reading on a timer bounds it to the MDM's sync
+ /// interval plus this.
+ private static let reconcileInterval: DispatchTimeInterval = .seconds(300)
+
+ /// How much of an elevation reason is kept in the audit log.
+ private static let maximumReasonLength = 512
+
+ private let queue = DispatchQueue(label: "com.github.macadmins.SupportCompanion.helper.elevation")
+ private var timers: [String: DispatchSourceTimer] = [:]
+ private var watchdog: DispatchSourceTimer?
+ private var reconciler: DispatchSourceTimer?
+ private var isCheckingAdmins = false
+
+ private init() {}
+
+ // MARK: State
+
+ private struct Elevation {
+ let userName: String
+ let deadline: Date
+ /// Who was an administrator when this window opened. Anyone who appears later was added
+ /// during it, by somebody who only has the rights to do so because we granted them.
+ let baseline: AdminSnapshot
+
+ var isActive: Bool { deadline > Date() }
+ }
+
+ /// The `admin` group as OpenDirectory records it, in both forms.
+ ///
+ /// Membership can be granted by short name or by UUID, and either one is enough to be an
+ /// administrator, so watching only the names would leave the other way in unwatched.
+ struct AdminSnapshot: Equatable {
+ var names: Set = []
+ var uuids: Set = []
+
+ func additions(over baseline: AdminSnapshot) -> AdminSnapshot {
+ AdminSnapshot(
+ names: names.subtracting(baseline.names),
+ uuids: uuids.subtracting(baseline.uuids)
+ )
+ }
+
+ var isEmpty: Bool { names.isEmpty && uuids.isEmpty }
+ }
+
+ private static func elevation(from dictionary: [String: Any]) -> Elevation? {
+ guard
+ let userName = dictionary["UserName"] as? String,
+ let deadline = dictionary["Deadline"] as? Date
+ else { return nil }
+
+ return Elevation(
+ userName: userName,
+ deadline: deadline,
+ baseline: AdminSnapshot(
+ names: Set(dictionary["BaselineAdminNames"] as? [String] ?? []),
+ uuids: Set(dictionary["BaselineAdminUUIDs"] as? [String] ?? [])
+ )
+ )
+ }
+
+ private func loadElevations() -> [Elevation] {
+ prepareStateDirectory()
+
+ guard FileManager.default.fileExists(atPath: Self.statePath) else { return [] }
+
+ // With the directory locked down this cannot happen, which is exactly why it is worth
+ // noticing if it ever does.
+ guard isOwnedByRoot(Self.statePath) else {
+ Logger.shared.logError("Discarding elevation state at \(Self.statePath): not owned by root")
+ try? FileManager.default.removeItem(atPath: Self.statePath)
+ return []
+ }
+
+ guard
+ let data = FileManager.default.contents(atPath: Self.statePath),
+ let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
+ else { return [] }
+
+ if let entries = plist["Elevations"] as? [[String: Any]] {
+ return entries.compactMap(Self.elevation(from:))
+ }
+
+ // A file written before this held one elevation at the top level. Read it so a window that is
+ // open across an upgrade is still closed rather than stranded.
+ return [Self.elevation(from: plist)].compactMap { $0 }
+ }
+
+ private func saveElevations(_ elevations: [Elevation]) {
+ guard !elevations.isEmpty else {
+ try? FileManager.default.removeItem(atPath: Self.statePath)
+ return
+ }
+
+ prepareStateDirectory()
+
+ let plist: [String: Any] = [
+ "Elevations": elevations.map { elevation in
+ [
+ "UserName": elevation.userName,
+ "Deadline": elevation.deadline,
+ "BaselineAdminNames": Array(elevation.baseline.names),
+ "BaselineAdminUUIDs": Array(elevation.baseline.uuids)
+ ] as [String: Any]
+ }
+ ]
+
+ guard let data = try? PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0) else {
+ Logger.shared.logError("Unable to serialize elevation state")
+ return
+ }
+
+ FileManager.default.createFile(
+ atPath: Self.statePath,
+ contents: data,
+ attributes: [.posixPermissions: 0o600, .ownerAccountID: 0]
+ )
+ }
+
+ private func isOwnedByRoot(_ path: String) -> Bool {
+ guard
+ let attributes = try? FileManager.default.attributesOfItem(atPath: path),
+ let owner = attributes[.ownerAccountID] as? NSNumber
+ else { return false }
+
+ return owner.uint32Value == 0
+ }
+
+ /// Make sure the state directory exists and that only root can write to it.
+ ///
+ /// Checked on every read and write rather than only at creation: the protection that matters is on
+ /// the directory, because deleting or replacing a file needs write permission there and not on the
+ /// file itself. Running as root, we can repair it rather than just complain.
+ private func prepareStateDirectory() {
+ let attributes: [FileAttributeKey: Any] = [.posixPermissions: 0o700, .ownerAccountID: 0, .groupOwnerAccountID: 0]
+
+ guard FileManager.default.fileExists(atPath: Self.stateDirectory) else {
+ try? FileManager.default.createDirectory(
+ atPath: Self.stateDirectory,
+ withIntermediateDirectories: true,
+ attributes: attributes
+ )
+ return
+ }
+
+ guard let current = try? FileManager.default.attributesOfItem(atPath: Self.stateDirectory) else { return }
+
+ let owner = (current[.ownerAccountID] as? NSNumber)?.uint32Value ?? 0
+ let permissions = (current[.posixPermissions] as? NSNumber)?.int16Value ?? 0
+
+ if owner != 0 || (permissions & 0o077) != 0 {
+ Logger.shared.logError("Repairing permissions on \(Self.stateDirectory)")
+ try? FileManager.default.setAttributes(attributes, ofItemAtPath: Self.stateDirectory)
+ }
+ }
+
+ // MARK: Audit log
+
+ /// Flatten text that came from outside before it is written to the audit log.
+ ///
+ /// The reason is typed by the user. One line per event is the only structure this log has, so a
+ /// reason containing a newline would let anyone with the elevation dialog write entries of their
+ /// choosing into a root-owned record — including lines that look like demotions that never
+ /// happened. Control characters go, and the length is bounded so the log cannot be inflated
+ /// without limit.
+ private static func sanitizedForAuditLog(_ text: String) -> String {
+ let flattened = text.unicodeScalars
+ .map { CharacterSet.controlCharacters.contains($0) ? " " : Character($0) }
+ .reduce(into: "") { $0.append($1) }
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard flattened.count > maximumReasonLength else { return flattened }
+
+ return String(flattened.prefix(maximumReasonLength)) + "…(truncated)"
+ }
+
+ /// Append to the root-owned record of who elevated and why.
+ ///
+ /// The app also writes a reason log under the user's Application Support folder, but the user can edit
+ /// that one, so it is a convenience rather than a record. This one the user cannot touch.
+ private func appendAuditLine(_ line: String) {
+ prepareStateDirectory()
+
+ let stamped = "\(ISO8601DateFormatter().string(from: Date())) \(Self.sanitizedForAuditLog(line))\n"
+ guard let data = stamped.data(using: .utf8) else { return }
+
+ if let handle = FileHandle(forWritingAtPath: Self.auditLogPath) {
+ defer { try? handle.close() }
+ try? handle.seekToEnd()
+ try? handle.write(contentsOf: data)
+ } else {
+ FileManager.default.createFile(
+ atPath: Self.auditLogPath,
+ contents: data,
+ attributes: [.posixPermissions: 0o600, .ownerAccountID: 0]
+ )
+ }
+ }
+
+ // MARK: Operations
+
+ func elevate(userName: String, reason: String, maximumMinutes: Int) async throws -> String {
+ let output = try await ExecutionService.run(
+ "/usr/sbin/dseditgroup",
+ ["-o", "edit", "-a", userName, "-t", "user", "admin"]
+ )
+
+ let minutes = max(1, maximumMinutes)
+ let deadline = Date().addingTimeInterval(TimeInterval(minutes * 60))
+
+ // Taken after the grant, so the user we just elevated is part of the baseline rather than
+ // the first thing the watchdog objects to.
+ let baseline = await currentAdminSnapshot()
+
+ queue.sync {
+ var elevations = loadElevations().filter { $0.userName != userName && $0.isActive }
+ elevations.append(Elevation(userName: userName, deadline: deadline, baseline: baseline))
+
+ saveElevations(elevations)
+ scheduleTimerLocked(for: deadline, userName: userName)
+ startWatchdogLocked()
+ }
+
+ let trimmedReason = Self.sanitizedForAuditLog(reason)
+ appendAuditLine("elevated \(userName) for \(minutes)m reason=\(trimmedReason.isEmpty ? "(none)" : trimmedReason)")
+ Logger.shared.logWarning("Elevated \(userName) until \(deadline)")
+
+ return output
+ }
+
+ func demote(userName: String) async throws -> String {
+ // One last look before the window closes. The watchdog stops when the last window does, so
+ // without this a grant made between the final tick and the deadline would never be seen.
+ // Named explicitly: by the time the timer fires this user's deadline has passed, so they no
+ // longer look active, and the sweep would otherwise skip the very window it is closing.
+ await checkForNewAdministrators(closingUser: userName)
+
+ let output = try await ExecutionService.run(
+ "/usr/sbin/dseditgroup",
+ ["-o", "edit", "-d", userName, "-t", "user", "admin"]
+ )
+
+ queue.sync {
+ let remaining = loadElevations().filter { $0.userName != userName && $0.isActive }
+ saveElevations(remaining)
+
+ cancelTimerLocked(for: userName)
+
+ // Other users may still be elevated; the watchdog belongs to the set, not to one window.
+ if remaining.isEmpty {
+ cancelWatchdogLocked()
+ }
+ }
+
+ appendAuditLine("demoted \(userName)")
+ Logger.shared.logWarning("Demoted \(userName)")
+
+ return output
+ }
+
+ func timeRemaining(userName: String) -> Double {
+ queue.sync {
+ guard let elevation = loadElevations().first(where: { $0.userName == userName }) else { return 0 }
+ return max(0, elevation.deadline.timeIntervalSinceNow)
+ }
+ }
+
+ /// Re-arm timers at launch, demote anything already overdue, and reconcile the admin group.
+ ///
+ /// The state file is the fast path, not the authority. An attacker with root can delete it and
+ /// restart us, and then nothing in it says a demotion was ever owed — which is why a missing state
+ /// file must not mean "nothing to do" wherever we have something better to go on.
+ func reconcileOnLaunch() {
+ let configuredAdmins = HelperPreferences.permanentAdmins
+ .map { $0.sorted().joined(separator: ", ") } ?? "(not configured)"
+ Logger.shared.logWarning("Helper starting: EnforceAdminAllowlist=\(HelperPreferences.enforceAdminAllowlist), PermanentAdmins=\(configuredAdmins)")
+
+ let elevations = queue.sync { loadElevations() }
+
+ for elevation in elevations {
+ if elevation.isActive {
+ Logger.shared.logWarning("Restoring demotion timer for \(elevation.userName), due \(elevation.deadline)")
+ queue.sync { scheduleTimerLocked(for: elevation.deadline, userName: elevation.userName) }
+ } else {
+ let overrun = Date().timeIntervalSince(elevation.deadline)
+ Logger.shared.logWarning("Elevation for \(elevation.userName) expired \(Int(overrun))s ago while the helper was not running")
+ appendAuditLine("overdue demotion for \(elevation.userName), \(Int(overrun))s past the deadline")
+ queue.sync { demoteInBackground(userName: elevation.userName) }
+ }
+ }
+
+ if elevations.contains(where: { $0.isActive }) {
+ queue.sync { startWatchdogLocked() }
+ }
+
+ // Started whatever the policy currently says, so that a profile removed and then re-asserted
+ // by the MDM takes effect without waiting for a restart.
+ queue.sync { startReconcilerLocked() }
+
+ Task { await reconcileAdmins() }
+ }
+
+ private func startReconcilerLocked() {
+ reconciler?.cancel()
+
+ let source = DispatchSource.makeTimerSource(queue: queue)
+ source.schedule(deadline: .now() + Self.reconcileInterval, repeating: Self.reconcileInterval)
+ source.setEventHandler { [weak self] in
+ guard let self else { return }
+ Task { await self.reconcileAdmins() }
+ }
+ source.resume()
+
+ reconciler = source
+ }
+
+ /// Compare the admin group against the allowlist and take back what it does not account for.
+ ///
+ /// The policy is re-read every time rather than cached, so removing the profile only suspends
+ /// enforcement for as long as it is actually missing.
+ private func reconcileAdmins() async {
+ guard HelperPreferences.enforceAdminAllowlist else { return }
+
+ // Enforcing with no list at all would demote every administrator on the Mac, which is far
+ // more likely to be a half-finished profile than somebody's intent. An explicitly empty list
+ // is honored; a missing one is refused.
+ guard var accountedFor = HelperPreferences.permanentAdmins else {
+ Logger.shared.logError("EnforceAdminAllowlist is set but PermanentAdmins is not configured. Refusing to enforce: set PermanentAdmins, using an empty array if no account should be permanently an administrator.")
+ return
+ }
+
+ // A live elevation window is the one legitimate reason to hold administrator rights without
+ // appearing in the allowlist.
+ for elevation in queue.sync(execute: { loadElevations() }) where elevation.isActive {
+ accountedFor.insert(elevation.userName)
+ }
+
+ await demoteUnaccountedAdmins(accountedFor: accountedFor)
+ }
+
+ /// Take administrator rights from accounts that policy does not account for.
+ ///
+ /// This is what makes deleting the state file the losing move rather than the winning one: with no
+ /// record of a grant, an elevated user is simply someone holding rights the allowlist does not
+ /// explain, and loses them at the next reconciliation.
+ private func demoteUnaccountedAdmins(accountedFor: Set) async {
+ let current = await currentAdminSnapshot()
+
+ // An empty read means dscl failed. Demoting every administrator on the strength of a failed
+ // command is not a risk worth taking.
+ guard !current.isEmpty else {
+ Logger.shared.logError("Skipping allowlist enforcement: unable to read the admin group")
+ return
+ }
+
+ var permitted = accountedFor
+ permitted.insert("root")
+
+ // Membership by UUID grants administrator rights exactly as a short name does, so an entry
+ // added that way has to be reconciled too. Resolving first means an allowlisted account added
+ // by UUID is recognized rather than stripped.
+ let (unexpectedNames, unexpectedUUIDs, unresolvedUUIDs) = await unaccountedMembers(in: current, permitted: permitted)
+
+ // Reported, never removed. This runs unattended every few minutes with no notion of when an
+ // entry appeared, so a failed lookup is not grounds for taking administrator rights away —
+ // the entry may predate this Mac's current directory configuration, or the account behind it
+ // may simply not be visible from here. Anything that appears *during* an elevation is caught
+ // by the watchdog instead, which knows what the group looked like beforehand.
+ if !unresolvedUUIDs.isEmpty {
+ Logger.shared.logWarning("Admin group contains \(unresolvedUUIDs.count) member(s) with no resolvable account, left alone: \(unresolvedUUIDs.sorted().joined(separator: ", "))")
+ }
+
+ guard !unexpectedNames.isEmpty || !unexpectedUUIDs.isEmpty else { return }
+
+ for name in unexpectedNames.sorted() {
+ // Never touch uid 0, whatever the account is called.
+ if let entry = getpwnam(name), entry.pointee.pw_uid == 0 {
+ Logger.shared.logWarning("Not demoting \(name): uid 0")
+ continue
+ }
+
+ do {
+ _ = try await ExecutionService.run("/usr/sbin/dseditgroup", ["-o", "edit", "-d", name, "-t", "user", "admin"])
+ Logger.shared.logWarning("Demoted \(name): not in PermanentAdmins and no active elevation")
+ appendAuditLine("allowlist enforcement demoted \(name)")
+ } catch {
+ Logger.shared.logError("Failed to demote \(name): \(error.localizedDescription)")
+ }
+ }
+
+ for uuid in unexpectedUUIDs.sorted() {
+ _ = try? await ExecutionService.run("/usr/bin/dscl", [".", "-delete", "/Groups/admin", "GroupMembers", uuid])
+ Logger.shared.logWarning("Removed admin group member \(uuid): no account, and not accounted for")
+ appendAuditLine("allowlist enforcement removed admin uuid \(uuid)")
+ }
+ }
+
+ /// Split admin group membership into what policy accounts for and what it does not, resolving
+ /// UUID entries to accounts so both forms are judged by the same list.
+ ///
+ /// Unresolvable UUIDs are reported separately rather than lumped in with the rest, because the two
+ /// callers should treat them differently. A UUID with no account behind it is not evidence of
+ /// anything: it may be an entry left over from a deleted account, or an identity this Mac cannot
+ /// see. Removing it is only safe where something else establishes that it appeared just now.
+ private func unaccountedMembers(
+ in snapshot: AdminSnapshot,
+ permitted: Set
+ ) async -> (names: Set, uuids: Set, unresolved: Set) {
+ var namesByUUID: [String: String] = [:]
+ var unresolved: Set = []
+
+ for uuid in snapshot.uuids {
+ if let name = await accountName(forGeneratedUID: uuid) {
+ namesByUUID[uuid] = name
+ } else {
+ unresolved.insert(uuid)
+ }
+ }
+
+ let names = snapshot.names.union(namesByUUID.values).subtracting(permitted)
+
+ // A UUID whose account is permitted stays.
+ let uuids = Set(namesByUUID.filter { !permitted.contains($0.value) }.keys)
+
+ return (names, uuids, unresolved)
+ }
+
+ // MARK: Timers
+
+ private func scheduleTimerLocked(for deadline: Date, userName: String) {
+ cancelTimerLocked(for: userName)
+
+ let source = DispatchSource.makeTimerSource(queue: queue)
+ source.schedule(deadline: .now() + max(0, deadline.timeIntervalSinceNow))
+ source.setEventHandler { [weak self] in
+ guard let self else { return }
+ Logger.shared.logWarning("Elevation expired for \(userName)")
+ self.demoteInBackground(userName: userName)
+ }
+ source.resume()
+
+ timers[userName] = source
+ }
+
+ private func cancelTimerLocked(for userName: String) {
+ timers[userName]?.cancel()
+ timers[userName] = nil
+ }
+
+ private func demoteInBackground(userName: String) {
+ Task {
+ do {
+ _ = try await self.demote(userName: userName)
+ } catch {
+ Logger.shared.logError("Failed to demote \(userName): \(error.localizedDescription)")
+ }
+ }
+ }
+
+ // MARK: Watchdog
+
+ /// Watch the `admin` group for as long as any window is open.
+ ///
+ /// Demoting one account at the end does nothing about a *second* administrator created during the
+ /// window, which outlives the elevation entirely. Those rights are taken back as soon as they
+ /// appear, and taken back again if they reappear, since the baselines do not move.
+ ///
+ /// Elevated users keep their own rights until their timers run out. They were granted for a
+ /// reason and the granted window is not what went wrong; the extra account is, and that is what
+ /// gets undone.
+ ///
+ /// This is containment, not prevention. Someone with root for a few seconds has other ways to
+ /// persist — a launch daemon, a sudoers drop-in, enabling the root account — none of which show up
+ /// in a group. What this does is close the cheapest and most common one, and make it noisy.
+ private func startWatchdogLocked() {
+ guard watchdog == nil else { return }
+
+ let source = DispatchSource.makeTimerSource(queue: queue)
+ source.schedule(deadline: .now(), repeating: Self.watchdogInterval)
+ source.setEventHandler { [weak self] in
+ guard let self, !self.isCheckingAdmins else { return }
+ self.isCheckingAdmins = true
+
+ Task {
+ await self.checkForNewAdministrators()
+ self.queue.async { self.isCheckingAdmins = false }
+ }
+ }
+ source.resume()
+
+ watchdog = source
+ }
+
+ private func cancelWatchdogLocked() {
+ watchdog?.cancel()
+ watchdog = nil
+ }
+
+ /// Look for administrator rights that appeared during an open window.
+ ///
+ /// - Parameter closingUser: the user whose window is being closed right now, if any. Their
+ /// deadline has just passed, so they no longer count as active — but this sweep exists
+ /// precisely to catch a grant made in the last moments before a window ends, and skipping it
+ /// because the clock ticked over would leave the normal expiry path unswept.
+ private func checkForNewAdministrators(closingUser: String? = nil) async {
+ let allElevations = queue.sync { loadElevations() }
+
+ let relevant = allElevations.filter { elevation in
+ elevation.isActive || elevation.userName == closingUser
+ }
+ guard !relevant.isEmpty else { return }
+
+ let current = await currentAdminSnapshot()
+
+ // An empty read means dscl failed; treat it as no information rather than as everyone leaving
+ guard !current.isEmpty else { return }
+
+ // Anyone who was an administrator when any open window started, plus accounts an
+ // administrator has declared as expected — for management accounts an MDM may legitimately
+ // add while somebody happens to be elevated.
+ var permitted = Set(
+ HelperPreferences.object(forKey: "ElevationAllowedAdmins", forUser: nil) as? [String] ?? []
+ )
+ permitted.insert("root")
+
+ // Every user we have granted rights to, whether or not their window is still open. One whose
+ // deadline has passed is waiting to be demoted, not an intruder — and without this, a user
+ // whose window ends while somebody else is still elevated gets written to the audit log as an
+ // unexplained administrator. That entry would be false, and this log is the record.
+ for elevation in allElevations {
+ permitted.insert(elevation.userName)
+ }
+
+ for elevation in relevant {
+ permitted.formUnion(elevation.baseline.names)
+ }
+
+ var baselineUUIDs: Set = []
+ for elevation in relevant {
+ baselineUUIDs.formUnion(elevation.baseline.uuids)
+ }
+
+ let candidates = AdminSnapshot(
+ names: current.names,
+ uuids: current.uuids.subtracting(baselineUUIDs)
+ )
+
+ // Unresolvable UUIDs are revoked here, unlike in the standing reconciler. The baseline was
+ // subtracted above, so everything left appeared after this window opened — which is the
+ // evidence the reconciler lacks, and what makes removing an unidentifiable entry reasonable.
+ let (namesToRevoke, resolvedUUIDs, unresolvedUUIDs) = await unaccountedMembers(in: candidates, permitted: permitted)
+ let uuidsToRevoke = resolvedUUIDs.union(unresolvedUUIDs)
+
+ guard !namesToRevoke.isEmpty || !uuidsToRevoke.isEmpty else { return }
+
+ let describedUsers = relevant.map(\.userName).sorted().joined(separator: ", ")
+ let describedNames = namesToRevoke.sorted().joined(separator: ", ")
+
+ Logger.shared.logError("New administrator(s) appeared during elevation of [\(describedUsers)]: \(describedNames.isEmpty ? uuidsToRevoke.sorted().joined(separator: ", ") : describedNames)")
+ appendAuditLine("admin granted during elevation of [\(describedUsers)] to [\(describedNames)] uuids=[\(uuidsToRevoke.sorted().joined(separator: ", "))]")
+
+ for name in namesToRevoke.sorted() {
+ if let entry = getpwnam(name), entry.pointee.pw_uid == 0 { continue }
+
+ do {
+ _ = try await ExecutionService.run("/usr/sbin/dseditgroup", ["-o", "edit", "-d", name, "-t", "user", "admin"])
+ appendAuditLine("revoked admin from \(name)")
+ } catch {
+ Logger.shared.logError("Failed to revoke admin from \(name): \(error.localizedDescription)")
+ }
+ }
+
+ // Removing the account by name normally clears its UUID entry too, so this is the fallback for
+ // a UUID with no account behind it and a second pass for the rest.
+ for uuid in uuidsToRevoke.sorted() {
+ _ = try? await ExecutionService.run("/usr/bin/dscl", [".", "-delete", "/Groups/admin", "GroupMembers", uuid])
+ }
+ }
+
+ /// Resolve a `GeneratedUID` back to a short account name, or `nil` when no account has it.
+ private func accountName(forGeneratedUID uuid: String) async -> String? {
+ guard
+ let output = try? await ExecutionService.run("/usr/bin/dscl", [".", "-search", "/Users", "GeneratedUID", uuid]),
+ let firstLine = output.split(separator: "\n").first,
+ let name = firstLine.split(whereSeparator: \.isWhitespace).first
+ else { return nil }
+
+ return String(name)
+ }
+
+ /// Read the `admin` group's membership, by name and by UUID.
+ private func currentAdminSnapshot() async -> AdminSnapshot {
+ guard let output = try? await ExecutionService.run(
+ "/usr/bin/dscl", [".", "-read", "/Groups/admin", "GroupMembership", "GroupMembers"]
+ ) else {
+ Logger.shared.logError("Unable to read the admin group")
+ return AdminSnapshot()
+ }
+
+ return Self.parseAdminSnapshot(output)
+ }
+
+ /// dscl prints `Key: a b c`, and wraps long values onto following indented lines, so each key's
+ /// value runs until the next line that starts a new key.
+ static func parseAdminSnapshot(_ output: String) -> AdminSnapshot {
+ var snapshot = AdminSnapshot()
+ var currentKey: String?
+
+ for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
+ let isContinuation = line.hasPrefix(" ") || line.hasPrefix("\t")
+ var values = Substring(line)
+
+ if !isContinuation, let colon = line.firstIndex(of: ":") {
+ currentKey = String(line[line.startIndex.. String {
- Logger.shared.logDebug("Executing script at \(path)")
- do {
- return try await ExecutionService.executeScript(at: path)
- } catch {
- Logger.shared.logError("Error: \(error.localizedDescription)")
- throw error
- }
- }
-
- func executeCommand(_ command: String, with arguments: [String] = []) async throws -> String {
- Logger.shared.logDebug("Executing command \(command) with arguments: \(arguments)")
- do {
- return try await ExecutionService.executeCommand(command, with: arguments)
- } catch {
- Logger.shared.logError("Error: \(error.localizedDescription)")
- throw error
- }
- }
-}
-
// MARK: - Run
extension Helper {
func run() {
+ // Take back any administrator rights that outlived the last run before accepting connections
+ ElevationCoordinator.shared.reconcileOnLaunch()
+
+ // Clear staged installers, and any disk image still attached, that a previous run left behind
+ InstallCoordinator.shared.reconcileOnLaunch()
+
// start listening on new connections
listener.resume()
@@ -68,16 +49,26 @@ extension Helper {
extension Helper: NSXPCListenerDelegate {
func listener(_ listener: NSXPCListener, shouldAcceptNewConnection newConnection: NSXPCConnection) -> Bool {
+ let clientUID: uid_t
+
do {
- try ConnectionIdentityService.checkConnectionIsValid(connection: newConnection)
+ clientUID = try ConnectionIdentityService.checkConnectionIsValid(connection: newConnection)
} catch {
Logger.shared.logError("🛑 Connection \(newConnection) has not been validated. \(error.localizedDescription)")
return false
}
+ guard let clientUserName = HelperService.userName(forUID: clientUID) else {
+ Logger.shared.logError("🛑 Connection \(newConnection) has no user account behind uid \(clientUID)")
+ return false
+ }
+
newConnection.exportedInterface = NSXPCInterface(with: HelperProtocol.self)
newConnection.remoteObjectInterface = NSXPCInterface(with: RemoteApplicationProtocol.self)
- newConnection.exportedObject = self
+
+ // One service object per connection, carrying the identity the kernel vouched for, so that
+ // operations act on the user who actually connected.
+ newConnection.exportedObject = HelperService(clientUID: clientUID, clientUserName: clientUserName)
newConnection.resume()
return true
diff --git a/Helper/HelperConstans.swift b/Helper/HelperConstans.swift
deleted file mode 100644
index d75b832..0000000
--- a/Helper/HelperConstans.swift
+++ /dev/null
@@ -1,18 +0,0 @@
-//
-// HelperConstans.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-12.
-//
-
-import Foundation
-
-enum HelperConstants {
- static let helpersFolder = "/Library/PrivilegedHelperTools/"
- static let domain = "com.github.macadmins.SupportCompanion.helper"
- static let helperPath = helpersFolder + domain
-
- static let bundleID = "com.github.macadmins.SupportCompanion"
- static let debugSubject = "H92SB6Z7S4"
- static let subject = "T4SK8ZXCXG"
-}
diff --git a/Helper/HelperConstants.swift b/Helper/HelperConstants.swift
new file mode 100644
index 0000000..fec761d
--- /dev/null
+++ b/Helper/HelperConstants.swift
@@ -0,0 +1,31 @@
+//
+// HelperConstants.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2024-11-12.
+//
+
+import Foundation
+
+enum HelperConstants {
+ static let helpersFolder = "/Library/PrivilegedHelperTools/"
+ static let domain = "com.github.macadmins.SupportCompanion.helper"
+ static let helperPath = helpersFolder + domain
+
+ static let bundleID = "com.github.macadmins.SupportCompanion"
+ static let appPath = "/Applications/SupportCompanion.app"
+
+ /// The oldest client this helper will serve.
+ ///
+ /// 2.x honored `IsPrivileged` from preference domains a standard user can write, so a copy of one
+ /// kept on disk was enough to reach this helper and get root. Compared component by component, so a
+ /// four-component build version such as `3.0.0.81132` satisfies a three-component minimum.
+ ///
+ /// Deliberately the marketing version and not a build number: the build suffix comes from
+ /// `git rev-list --count`, which drops if history is ever rewritten, and a floor above the version
+ /// a later build reports would make the helper refuse its own app. Raise this only when a release
+ /// fixes a privilege check that lives in the app.
+ static let minimumClientVersion = "3.0.0"
+ static let debugSubject = "\"42EJ7ZYMPQ\""
+ static let subject = "\"T4SK8ZXCXG\""
+}
diff --git a/Helper/HelperExecutionService.swift b/Helper/HelperExecutionService.swift
index e7ec967..0f98cb1 100644
--- a/Helper/HelperExecutionService.swift
+++ b/Helper/HelperExecutionService.swift
@@ -9,60 +9,45 @@ import Foundation
// MARK: - ExecutionService
-/// Execute a script.
+/// Runs the commands behind the helper's operations.
+///
+/// Nothing here is reachable from a client directly. Every caller is a named operation in `HelperService`
+/// that supplies its own executable path, so the set of programs the helper can run as root is fixed at
+/// compile time — with the single exception of `shell(_:)`, which runs an administrator-defined action.
enum ExecutionService {
- // MARK: Constants
-
- static let programURL = URL(fileURLWithPath: "/usr/bin/env")
-
- // MARK: Execute
-
- /// Execute the script at the provided URL.
- static func executeScript(at path: String) async throws -> String {
- let process = Process()
- process.executableURL = programURL
- process.arguments = [path]
-
- let outputPipe = Pipe()
- process.standardOutput = outputPipe
- process.standardError = outputPipe
- try process.run()
-
- return try await Task {
- let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
-
- guard let output = String(data: outputData, encoding: .utf8) else {
- throw SupportCompanionErrors.invalidStringConversion
- }
-
- return output
+ /// Run an executable by absolute path.
+ ///
+ /// Absolute paths only: the helper's `PATH` comes from launchd, and a root daemon has no business
+ /// resolving program names through it.
+ static func run(_ executable: String, _ arguments: [String] = []) async throws -> String {
+ guard executable.hasPrefix("/") else {
+ throw SupportCompanionErrors.helperConnection("Refusing to run '\(executable)': not an absolute path")
}
- .value
- }
-
- static func executeCommand(_ command: String, with arguments: [String] = []) async throws -> String {
- let process = Process()
- process.executableURL = programURL
- process.arguments = [command] + arguments
-
- let outputPipe = Pipe()
- let errorPipe = Pipe()
- process.standardOutput = outputPipe
- process.standardError = errorPipe
- try process.run()
- process.waitUntilExit()
-
- // Capture and check for errors
- if process.terminationStatus != 0 {
- let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
- let errorOutput = String(data: errorData, encoding: .utf8) ?? "Unknown error"
- throw NSError(domain: "ExecutionServiceError", code: Int(process.terminationStatus), userInfo: [NSLocalizedDescriptionKey: errorOutput])
+ let result = try await ProcessRunner.run(
+ executableURL: URL(fileURLWithPath: executable),
+ arguments: arguments
+ )
+
+ guard result.status == 0 else {
+ let errorOutput = String(data: result.error, encoding: .utf8) ?? "Unknown error"
+ throw NSError(
+ domain: "HelperExecutionError",
+ code: Int(result.status),
+ userInfo: [
+ NSLocalizedDescriptionKey: "'\(executable)' failed with status \(result.status): \(errorOutput)"
+ ]
+ )
}
- // Capture and return the output
- let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
- return String(data: outputData, encoding: .utf8) ?? ""
+ return String(data: result.output, encoding: .utf8) ?? ""
+ }
+
+ /// Run an administrator-defined action command through `/bin/sh`.
+ ///
+ /// The command always comes from `HelperPreferences`, never from the connection.
+ static func shell(_ command: String) async throws -> String {
+ try await run("/bin/sh", ["-c", command])
}
}
diff --git a/Helper/HelperPreferences.swift b/Helper/HelperPreferences.swift
new file mode 100644
index 0000000..fc27289
--- /dev/null
+++ b/Helper/HelperPreferences.swift
@@ -0,0 +1,270 @@
+//
+// HelperPreferences.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import Foundation
+
+/// Reads the settings that decide what the helper will do, from sources only an administrator can write.
+///
+/// The app has its own copy of this logic in `TrustedPreferences`, but the app's copy only decides what
+/// to *offer*. Anything that runs as root is decided here, because a client can be any build of the app
+/// and its view of the preferences is not evidence of anything.
+///
+/// Running as root also means `CFPreferences` cannot be used the way the app uses it: the helper is not
+/// the logged-in user, so it would never see a configuration profile scoped to that user. The files are
+/// read directly instead, in the order a managed setting would win:
+///
+/// 1. `/Library/Managed Preferences//.plist` — profile scoped to the connecting user
+/// 2. `/Library/Managed Preferences/.plist` — profile scoped to the device
+///
+/// `/Library/Preferences/.plist` is deliberately **not** among them. Every setting this type
+/// supplies decides something privileged — who may hold administrator rights, which commands run as
+/// root, which installers are allowed — and that file is one any process already running as root may
+/// write, with nothing to correct it afterwards. A single root moment would become a permanent grant
+/// of all three. A managed preference cannot be forged that way for long: the MDM owns those files
+/// and puts them back, which is the same reasoning behind re-reading the admin allowlist on a timer.
+///
+/// For development, put the plist in `/Library/Managed Preferences/` rather than reaching for the
+/// local path; root can write there directly and the helper will read it.
+enum HelperPreferences {
+
+ private static let domain = "com.github.macadmins.SupportCompanion"
+
+ /// Account names that are safe to put in a path.
+ ///
+ /// The name comes from `getpwuid`, which is trustworthy for local accounts — but on a
+ /// directory-bound Mac it comes from whatever the directory service returns, and a name
+ /// containing `/` or `..` would redirect the read to a file of somebody else's choosing.
+ private static func isUsable(_ userName: String) -> Bool {
+ !userName.isEmpty
+ && userName != "."
+ && userName != ".."
+ // ASCII deliberately, matching what the comment claims. `isLetter` and `isNumber` are
+ // Unicode-wide, which is harmless here but says something broader than it means.
+ && userName.allSatisfy { $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "." || $0 == "_" || $0 == "-") }
+ }
+
+ private static func searchPaths(forUser userName: String?) -> [String] {
+ var paths: [String] = []
+
+ if let userName, isUsable(userName) {
+ paths.append("/Library/Managed Preferences/\(userName)/\(domain).plist")
+ } else if let userName, !userName.isEmpty {
+ Logger.shared.logError("Ignoring the user-scoped preferences path: '\(userName)' is not a usable account name")
+ }
+
+ paths.append("/Library/Managed Preferences/\(domain).plist")
+
+ return paths
+ }
+
+ /// The local file this type used to read, kept only so that finding settings in it can be said
+ /// out loud rather than silently ignored.
+ private static var unmanagedPath: String { "/Library/Preferences/\(domain).plist" }
+
+ /// Warn when a setting lives somewhere that is no longer honoured.
+ ///
+ /// Dropping the local path is a behaviour change for anyone who configured it with a tool other
+ /// than an MDM. Silently falling back to defaults would look like the feature breaking for no
+ /// reason, so the reason is logged.
+ private static func warnIfPresentUnmanaged(_ key: String) {
+ guard let plist = contents(ofPlistAt: unmanagedPath), plist[key] != nil else { return }
+
+ Logger.shared.logError(
+ "Ignoring '\(key)' in \(unmanagedPath): settings that grant privileges are only read from a configuration profile. Deliver it through your MDM."
+ )
+ }
+
+ /// Load a preference file, ignoring any that is not owned by root.
+ ///
+ /// All three directories are root-owned, so a non-root owner means the file predates that or was
+ /// planted while something was misconfigured. Either way it is not an administrator's intent.
+ private static func contents(ofPlistAt path: String) -> [String: Any]? {
+ guard FileManager.default.fileExists(atPath: path) else { return nil }
+
+ do {
+ let attributes = try FileManager.default.attributesOfItem(atPath: path)
+
+ guard let owner = attributes[.ownerAccountID] as? NSNumber, owner.uint32Value == 0 else {
+ Logger.shared.logError("Ignoring \(path): not owned by root")
+ return nil
+ }
+
+ // Root-owned is not the same as only root-writable. The directories above make this
+ // unreachable today, which is exactly why the check belongs here rather than being
+ // assumed from somewhere else.
+ if let permissions = (attributes[.posixPermissions] as? NSNumber)?.uint16Value,
+ permissions & 0o022 != 0 {
+ Logger.shared.logError("Ignoring \(path): writable by group or other")
+ return nil
+ }
+ } catch {
+ Logger.shared.logError("Unable to check ownership of \(path): \(error.localizedDescription)")
+ return nil
+ }
+
+ guard
+ let data = FileManager.default.contents(atPath: path),
+ let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]
+ else {
+ Logger.shared.logError("Unable to read \(path) as a property list")
+ return nil
+ }
+
+ return plist
+ }
+
+ static func object(forKey key: String, forUser userName: String?) -> Any? {
+ for path in searchPaths(forUser: userName) {
+ if let value = contents(ofPlistAt: path)?[key] {
+ return value
+ }
+ }
+
+ warnIfPresentUnmanaged(key)
+
+ return nil
+ }
+
+ static func bool(forKey key: String, default defaultValue: Bool, forUser userName: String?) -> Bool {
+ let value = object(forKey: key, forUser: userName)
+ return (value as? Bool) ?? (value as? NSNumber)?.boolValue ?? defaultValue
+ }
+
+ static func int(forKey key: String, default defaultValue: Int, forUser userName: String?) -> Int {
+ let value = object(forKey: key, forUser: userName)
+ return (value as? Int) ?? (value as? NSNumber)?.intValue ?? defaultValue
+ }
+
+ // MARK: Admin allowlist
+
+ /// Whether to take administrator rights from accounts the allowlist does not account for.
+ ///
+ /// Read device-scoped, with no user: reconciliation runs at startup and on a timer, when there is
+ /// no connecting user to attribute it to. These keys must come from a device-scoped profile.
+ static var enforceAdminAllowlist: Bool {
+ bool(forKey: "EnforceAdminAllowlist", default: false, forUser: nil)
+ }
+
+ /// Accounts that may hold administrator rights without the helper having granted them.
+ ///
+ /// `nil` when the key is absent or is not a list of strings, which is different from an empty
+ /// list. An empty list is a coherent policy — nobody is permanently an administrator, every
+ /// administrator is a live elevation. A missing key is a half-finished configuration, and acting
+ /// on it would demote every administrator on the Mac.
+ static var permanentAdmins: Set? {
+ guard let names = object(forKey: "PermanentAdmins", forUser: nil) as? [String] else { return nil }
+ return Set(names)
+ }
+
+ /// The `Command` of an administrator-defined action, or `nil` when there is no such action or it is
+ /// not marked `IsPrivileged`.
+ static func privilegedActionCommand(named name: String, forUser userName: String?) -> String? {
+ guard let actions = object(forKey: "Actions", forUser: userName) as? [[String: Any]] else {
+ Logger.shared.logError("No administrator-defined Actions found")
+ return nil
+ }
+
+ guard let action = actions.first(where: { ($0["Name"] as? String) == name }) else {
+ Logger.shared.logError("No administrator-defined action named '\(name)'")
+ return nil
+ }
+
+ let isPrivileged = (action["IsPrivileged"] as? Bool)
+ ?? (action["IsPrivileged"] as? NSNumber)?.boolValue
+ ?? false
+
+ guard isPrivileged else {
+ Logger.shared.logError("Action '\(name)' is not marked IsPrivileged")
+ return nil
+ }
+
+ guard let command = action["Command"] as? String, !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ Logger.shared.logError("Action '\(name)' has no Command")
+ return nil
+ }
+
+ return command
+ }
+
+ /// Where the helper looked for a setting and what it found at each place.
+ ///
+ /// The app and the helper read preferences by different routes — the app through `CFPreferences`,
+ /// which knows about profiles scoped to the logged-in user, and the helper by reading the files,
+ /// because as root it is not that user and would never be shown their profile. The two can
+ /// therefore disagree, and when they do it is the helper's answer that decides. This says, in one
+ /// line, what the helper's answer was built from.
+ static func describeSearch(forKey key: String, forUser userName: String?) -> String {
+ searchPaths(forUser: userName).map { path in
+ guard FileManager.default.fileExists(atPath: path) else {
+ return "\(path): absent"
+ }
+
+ guard let plist = contents(ofPlistAt: path) else {
+ return "\(path): unreadable, or not owned by root"
+ }
+
+ guard let value = plist[key] else {
+ return "\(path): present, but has no \(key)"
+ }
+
+ return "\(path): \(key) = \(value)"
+ }
+ .joined(separator: " | ")
+ }
+
+ // MARK: User installs
+
+ /// Whether a standard user may install allowlisted applications through the helper.
+ static func enableUserInstalls(forUser userName: String?) -> Bool {
+ bool(forKey: "EnableUserInstalls", default: false, forUser: userName)
+ }
+
+ /// Whether the app must authenticate the user before asking for an install.
+ ///
+ /// The point of the feature is that nobody types an administrator password any more. Replacing that
+ /// with nothing at all would make an unlocked, unattended Mac enough, so the default is to ask the
+ /// user for their own credentials instead.
+ static func requireAuthenticationForInstalls(forUser userName: String?) -> Bool {
+ bool(forKey: "RequireAuthenticationForInstalls", default: true, forUser: userName)
+ }
+
+ /// What the app should offer when an installer is not on the allowlist.
+ ///
+ /// `installer` is the default because it is what a double-click does when Support Companion is not
+ /// involved at all: the file opens in Installer.app and the administrator password is asked for as
+ /// it always was. Nothing the user could do before stops working because this feature exists.
+ static func installFallback(forUser userName: String?) -> InstallerAssessment.Fallback {
+ let raw = (object(forKey: "UserInstallFallback", forUser: userName) as? String)?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+
+ guard let raw, let fallback = InstallerAssessment.Fallback(rawValue: raw) else {
+ return .installer
+ }
+
+ return fallback
+ }
+
+ /// The applications a standard user may install, as an administrator wrote them.
+ ///
+ /// Entries that cannot decide anything are dropped and logged rather than treated as permissive: a
+ /// half-written entry is a mistake, and the safe reading of a mistake is that nothing was allowed.
+ static func allowedInstallers(forUser userName: String?) -> [AllowedInstaller] {
+ guard let raw = object(forKey: "AllowedInstallers", forUser: userName) as? [[String: Any]] else {
+ Logger.shared.logError("No administrator-defined AllowedInstallers found")
+ return []
+ }
+
+ return raw.compactMap { dictionary in
+ let (entry, problem) = AllowedInstaller.make(from: dictionary)
+
+ if let problem {
+ Logger.shared.logError("Ignoring an AllowedInstallers entry: \(problem)")
+ }
+
+ return entry
+ }
+ }
+}
diff --git a/Helper/HelperProtocol.swift b/Helper/HelperProtocol.swift
index b0e95e4..905b714 100644
--- a/Helper/HelperProtocol.swift
+++ b/Helper/HelperProtocol.swift
@@ -7,8 +7,76 @@
import Foundation
+/// The operations the privileged helper will perform as root.
+///
+/// Every method is a named operation rather than a command to run. The helper decides what each one
+/// executes, so a caller cannot ask it to run something of the caller's choosing, and the policy that
+/// decides whether an operation is allowed at all is re-checked inside the helper against preferences
+/// only an administrator can write. See `HelperPreferences`.
@objc(HelperProtocol)
public protocol HelperProtocol {
- @objc func executeScript(at path: String) async throws -> String
- @objc func executeCommand(_ command: String, with arguments: [String]) async throws -> String
+
+ // MARK: Elevation
+
+ /// Add the connecting user to the `admin` group, and arm the root-side demotion timer.
+ @objc func elevate(reason: String) async throws -> String
+
+ /// Remove the connecting user from the `admin` group.
+ @objc func demote() async throws -> String
+
+ /// Seconds left before the helper demotes the connecting user, or 0 when no elevation is active.
+ @objc func elevationTimeRemaining() async throws -> Double
+
+ // MARK: Actions
+
+ /// Run the `Command` of an administrator-defined action marked `IsPrivileged`.
+ ///
+ /// Only the name crosses the connection. The helper looks the action up itself, so the command it
+ /// runs is always the one the administrator configured.
+ @objc func runPrivilegedAction(named name: String) async throws -> String
+
+ // MARK: User installs
+
+ /// Copy an installer the user chose into root-owned storage, and judge that copy.
+ ///
+ /// The subject comes from the client here, unlike every other operation: the user picked the file.
+ /// The decision does not. The helper copies the bytes behind the descriptor somewhere only root can
+ /// write, checks the copy against the allowlist in administrator-managed preferences, and returns a
+ /// token naming that copy. `installStagedInstaller` installs the same copy, so nothing the client
+ /// does in between can change what gets installed.
+ ///
+ /// A descriptor rather than a path, because the app holds it as the logged-in user: it can only
+ /// open what that user could already read, where a path would let a client have root read and
+ /// measure files the user has no access to.
+ ///
+ /// Returns an `InstallerAssessment` as JSON.
+ @objc func stageInstaller(_ installer: FileHandle, fileName: String) async throws -> String
+
+ /// Install the staged copy named by `token`, if the assessment allowed it.
+ @objc func installStagedInstaller(token: String) async throws -> String
+
+ /// Throw away a staged copy the user decided against, without waiting for it to expire.
+ @objc func discardStagedInstaller(token: String) async throws -> String
+
+ // MARK: Jamf
+
+ @objc func jamfPatch(id: String) async throws -> String
+ @objc func jamfSelfServicePatch(id: String, userId: String?) async throws -> String
+ @objc func jamfRecon() async throws -> String
+
+ /// Read one of the fixed Jamf log queries. `kind` is `checkIn` or `inventory`.
+ @objc func jamfLog(kind: String, hours: Int) async throws -> String
+
+ // MARK: Device management
+
+ @objc func restartIntuneAgent() async throws -> String
+
+ /// The install date of the MDM enrollment profile, as `yyyy-MM-dd`, or an empty string.
+ @objc func mdmEnrollmentDate() async throws -> String
+
+ // MARK: System
+
+ @objc func reboot() async throws -> String
+ @objc func cancelReboot() async throws -> String
+ @objc func setIPConfigVerbose(_ enabled: Bool) async throws -> String
}
diff --git a/Helper/HelperService.swift b/Helper/HelperService.swift
new file mode 100644
index 0000000..5ca62ed
--- /dev/null
+++ b/Helper/HelperService.swift
@@ -0,0 +1,261 @@
+//
+// HelperService.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import Foundation
+
+/// The object exported over one validated connection.
+///
+/// One instance per connection, holding the identity of the process on the other end as the kernel
+/// reported it in the audit token. Operations that act on "the user" act on that identity, so a client
+/// cannot ask the helper to elevate somebody else.
+final class HelperService: NSObject {
+
+ private let clientUID: uid_t
+ private let clientUserName: String
+
+ init(clientUID: uid_t, clientUserName: String) {
+ self.clientUID = clientUID
+ self.clientUserName = clientUserName
+ super.init()
+ }
+
+ /// Resolve a uid to a short user name, for `dseditgroup` and for finding managed preferences.
+ static func userName(forUID uid: uid_t) -> String? {
+ guard let entry = getpwuid(uid) else { return nil }
+ return String(cString: entry.pointee.pw_name)
+ }
+}
+
+// MARK: - Validation
+
+extension HelperService {
+
+ /// Jamf ids are numbers. They reach the command as a separate argument rather than inside a shell
+ /// string, so this is belt and braces, but a malformed id is a bug worth refusing either way.
+ private func validatedJamfID(_ id: String) throws -> String {
+ guard !id.isEmpty, id.allSatisfy(\.isNumber) else {
+ throw SupportCompanionErrors.helperConnection("'\(id)' is not a valid Jamf id")
+ }
+
+ return id
+ }
+
+ private func validatedUserID(_ userId: String?) throws -> String? {
+ guard let userId, !userId.isEmpty else { return nil }
+
+ guard !userId.contains(where: { $0.isNewline || $0 == "\0" }) else {
+ throw SupportCompanionErrors.helperConnection("Jamf user id contains illegal characters")
+ }
+
+ return userId
+ }
+
+ /// Reduce a client-supplied file name to something safe to log and to derive an extension from.
+ ///
+ /// Only ever used for display, for the log, and for its extension — never to build a path to read
+ /// from. The staging file is named by the helper. See `StagedFile.copy(from:toDirectory:fileName:)`.
+ private func sanitizedFileName(_ fileName: String) throws -> String {
+ let name = (fileName as NSString).lastPathComponent
+ .unicodeScalars
+ .map { CharacterSet.controlCharacters.contains($0) || $0 == "/" ? " " : Character($0) }
+ .reduce(into: "") { $0.append($1) }
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ guard !name.isEmpty, name != ".", name != ".." else {
+ throw SupportCompanionErrors.helperConnection("'\(fileName)' is not a usable file name")
+ }
+
+ return String(name.prefix(255))
+ }
+
+ /// Refuse to act on root or on a uid with no account behind it.
+ private func validatedElevationTarget() throws -> String {
+ guard clientUID != 0 else {
+ throw SupportCompanionErrors.helperConnection("Refusing to change group membership for root")
+ }
+
+ guard !clientUserName.isEmpty else {
+ throw SupportCompanionErrors.helperConnection("No user account for uid \(clientUID)")
+ }
+
+ return clientUserName
+ }
+}
+
+// MARK: - HelperProtocol
+
+extension HelperService: HelperProtocol {
+
+ // MARK: Elevation
+
+ func elevate(reason: String) async throws -> String {
+ let userName = try validatedElevationTarget()
+
+ // The app hides the button when elevation is off, but that is presentation. This is the check
+ // that decides, and it reads a preference only an administrator can write.
+ guard HelperPreferences.bool(forKey: "EnableElevation", default: false, forUser: userName) else {
+ Logger.shared.logError("Refusing to elevate \(userName): EnableElevation is not set by an administrator")
+ throw SupportCompanionErrors.helperConnection("Elevation is not enabled by an administrator")
+ }
+
+ let maximumMinutes = HelperPreferences.int(forKey: "MaxElevationTime", default: 5, forUser: userName)
+
+ return try await ElevationCoordinator.shared.elevate(
+ userName: userName,
+ reason: reason,
+ maximumMinutes: maximumMinutes
+ )
+ }
+
+ func demote() async throws -> String {
+ let userName = try validatedElevationTarget()
+ return try await ElevationCoordinator.shared.demote(userName: userName)
+ }
+
+ func elevationTimeRemaining() async throws -> Double {
+ ElevationCoordinator.shared.timeRemaining(userName: clientUserName)
+ }
+
+ // MARK: Actions
+
+ func runPrivilegedAction(named name: String) async throws -> String {
+ guard let command = HelperPreferences.privilegedActionCommand(named: name, forUser: clientUserName) else {
+ throw SupportCompanionErrors.helperConnection("No privileged action named '\(name)' is configured by an administrator")
+ }
+
+ Logger.shared.logInfo("Running privileged action '\(name)' for \(clientUserName)")
+
+ return try await ExecutionService.shell(command)
+ }
+
+ // MARK: User installs
+
+ func stageInstaller(_ installer: FileHandle, fileName: String) async throws -> String {
+ guard clientUID != 0, !clientUserName.isEmpty else {
+ throw SupportCompanionErrors.helperConnection("No user account for uid \(clientUID)")
+ }
+
+ let name = try sanitizedFileName(fileName)
+
+ Logger.shared.logInfo("Assessing installer '\(name)' for \(clientUserName)")
+
+ let assessment = try await InstallCoordinator.shared.stage(
+ from: installer,
+ fileName: name,
+ clientUID: clientUID,
+ clientUserName: clientUserName
+ )
+
+ return try assessment.jsonString()
+ }
+
+ func installStagedInstaller(token: String) async throws -> String {
+ guard UUID(uuidString: token) != nil else {
+ throw SupportCompanionErrors.helperConnection("That is not a staged installer")
+ }
+
+ Logger.shared.logInfo("Installing staged installer for \(clientUserName)")
+
+ return try await InstallCoordinator.shared.install(
+ token: token,
+ clientUID: clientUID,
+ clientUserName: clientUserName
+ )
+ }
+
+ func discardStagedInstaller(token: String) async throws -> String {
+ guard UUID(uuidString: token) != nil else {
+ throw SupportCompanionErrors.helperConnection("That is not a staged installer")
+ }
+
+ await InstallCoordinator.shared.discard(token, clientUID: clientUID)
+
+ return ""
+ }
+
+ // MARK: Jamf
+
+ func jamfPatch(id: String) async throws -> String {
+ let id = try validatedJamfID(id)
+ return try await ExecutionService.run("/usr/local/bin/jamf", ["patch", "-id", id])
+ }
+
+ func jamfSelfServicePatch(id: String, userId: String?) async throws -> String {
+ let id = try validatedJamfID(id)
+
+ var arguments = [
+ "asuser", String(clientUID),
+ "/usr/local/bin/jamf", "patch", "-id", id, "-showSteps", "-selfServiceOnly"
+ ]
+
+ if let userId = try validatedUserID(userId) {
+ arguments += ["-user", userId]
+ }
+
+ return try await ExecutionService.run("/bin/launchctl", arguments)
+ }
+
+ func jamfRecon() async throws -> String {
+ try await ExecutionService.run("/usr/local/bin/jamf", ["recon", "-concurrent"])
+ }
+
+ func jamfLog(kind: String, hours: Int) async throws -> String {
+ let predicate: String
+
+ switch kind {
+ case "checkIn":
+ predicate = #"process == "jamf" AND eventMessage CONTAINS "recurring check-in""#
+ case "inventory":
+ predicate = #"process == "jamf" AND eventMessage CONTAINS "Submitting data""#
+ default:
+ throw SupportCompanionErrors.helperConnection("Unknown Jamf log query '\(kind)'")
+ }
+
+ let clampedHours = min(max(hours, 1), 168)
+
+ return try await ExecutionService.run("/usr/bin/log", [
+ "show",
+ "--predicate", predicate,
+ "--last", "\(clampedHours)h",
+ "--style", "syslog"
+ ])
+ }
+
+ // MARK: Device management
+
+ func restartIntuneAgent() async throws -> String {
+ try await ExecutionService.run("/usr/bin/killall", ["IntuneMdmAgent"])
+ }
+
+ func mdmEnrollmentDate() async throws -> String {
+ // Find the enrollment profile by its com.apple.mdm payload rather than by name, since every MDM
+ // names it differently (Jamf "MDM Profile", Intune "Management Profile", …). The pipeline is
+ // fixed here in the helper; nothing in it comes from the connection.
+ let installDate = #"(//dict[key[.='PayloadType']/following-sibling::*[1][.='com.apple.mdm']])[1]/../../key[.='ProfileInstallDate']/following-sibling::*[1]/text()"#
+ let command = #"/usr/bin/profiles -C -o stdout-xml | /usr/bin/xmllint --xpath "\#(installDate)" - 2>/dev/null || true"#
+
+ let output = try await ExecutionService.shell(command)
+
+ guard let range = output.range(of: #"\d{4}-\d{2}-\d{2}"#, options: .regularExpression) else {
+ return ""
+ }
+
+ return String(output[range])
+ }
+
+ // MARK: System
+
+ func reboot() async throws -> String {
+ try await ExecutionService.run("/sbin/shutdown", ["-r", "+1"])
+ }
+
+ func cancelReboot() async throws -> String {
+ try await ExecutionService.run("/usr/bin/killall", ["shutdown"])
+ }
+
+ func setIPConfigVerbose(_ enabled: Bool) async throws -> String {
+ try await ExecutionService.run("/usr/sbin/ipconfig", ["setverbose", enabled ? "1" : "0"])
+ }
+}
diff --git a/Helper/InstallCoordinator.swift b/Helper/InstallCoordinator.swift
new file mode 100644
index 0000000..8efb40f
--- /dev/null
+++ b/Helper/InstallCoordinator.swift
@@ -0,0 +1,552 @@
+//
+// InstallCoordinator.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import Darwin
+import Foundation
+
+/// Installs applications an administrator has allowed, on behalf of a user who is not an administrator.
+///
+/// The work is in two steps on purpose. `stage` copies the installer somewhere only root can write and
+/// judges it *there*; `install` installs that same copy, named only by an opaque token. Nothing the
+/// client says between the two can change what gets installed, so this keeps the property the rest of
+/// the helper has: the caller names an operation, and the helper decides what it does.
+actor InstallCoordinator {
+
+ static let shared = InstallCoordinator()
+
+ private static let auditLogPath = HelperState.directory + "/installs.log"
+
+ /// How long a staged installer waits for the user to make up their mind.
+ ///
+ /// A disk image stays mounted for this long, so it is short. The app re-stages if the sheet has
+ /// been sitting open longer than this.
+ private static let stagingLifetime: TimeInterval = 5 * 60
+
+ private struct Staged {
+ let directory: String
+ /// The package to install, or the application to copy.
+ let payloadPath: String
+ let kind: InstallerFacts.Kind
+ let mount: DiskImageInspector.Mount?
+ let facts: InstallerFacts
+ let entryName: String
+ let matchMode: InstallerMatchMode
+ let clientUID: uid_t
+ let createdAt: Date
+ }
+
+ private var staged: [String: Staged] = [:]
+
+ /// Disk images attached while an assessment is still running, by staging directory.
+ ///
+ /// On the actor rather than in a local, so that cleaning up after a failure part-way through the
+ /// assessment can find what was mounted. A directory holding a mount point cannot be removed until
+ /// the image is detached, and detaching is asynchronous, so this cannot live in a `defer`.
+ private var mountsInProgress: [String: DiskImageInspector.Mount] = [:]
+
+ /// Only one install at a time. Without this a client can start as many root `installer` processes
+ /// as it can click, and two installs writing to `/Applications` at once is its own problem.
+ private var isInstalling = false
+
+ private init() {}
+
+ // MARK: Launch
+
+ /// Clear anything a previous run left behind.
+ ///
+ /// A crash mid-assessment leaves a staged copy, and possibly a disk image still attached. Neither
+ /// is dangerous — both are inside a root-only directory — but they occupy disk until something
+ /// tidies up, and nothing else will.
+ nonisolated func reconcileOnLaunch() {
+ Task { await tidyLeftovers() }
+ }
+
+ private func tidyLeftovers() async {
+ guard FileManager.default.fileExists(atPath: StagedFile.root) else { return }
+
+ let leftovers = (try? FileManager.default.contentsOfDirectory(atPath: StagedFile.root)) ?? []
+
+ for leftover in leftovers {
+ let directory = (StagedFile.root as NSString).appendingPathComponent(leftover)
+ let mountRoot = (directory as NSString).appendingPathComponent("mnt")
+
+ for mounted in (try? FileManager.default.contentsOfDirectory(atPath: mountRoot)) ?? [] {
+ let path = (mountRoot as NSString).appendingPathComponent(mounted)
+ _ = try? await ExecutionService.run("/usr/bin/hdiutil", ["detach", path, "-force", "-quiet"])
+ }
+
+ StagedFile.remove(directory)
+ Logger.shared.logInfo("Removed a staged installer left behind by a previous run")
+ }
+ }
+
+ // MARK: Staging
+
+ /// Copy an installer out of the user's reach, work out what it is, and decide whether it is allowed.
+ func stage(
+ from handle: FileHandle,
+ fileName: String,
+ clientUID: uid_t,
+ clientUserName: String
+ ) async throws -> InstallerAssessment {
+ await expireStale()
+
+ // The app hides all of this when it is off, but that is presentation. This is the check that
+ // decides, and it reads a preference only an administrator can write.
+ //
+ // This throws before anything is staged, which means nothing reaches the audit log either. An
+ // empty `installs.log` and a feature that appears to do nothing are the same symptom, so the
+ // reason travels back to the client rather than only being implied by the silence.
+ guard HelperPreferences.enableUserInstalls(forUser: clientUserName) else {
+ let searched = HelperPreferences.describeSearch(forKey: "EnableUserInstalls", forUser: clientUserName)
+ Logger.shared.logError("Refusing to stage an installer for \(clientUserName): EnableUserInstalls is not set. \(searched)")
+
+ throw SupportCompanionErrors.helperConnection(
+ "Installing applications is not enabled by an administrator. The helper read: \(searched)"
+ )
+ }
+
+ let directory = try StagedFile.makeStagingDirectory()
+
+ do {
+ return try await assess(
+ from: handle,
+ fileName: fileName,
+ clientUID: clientUID,
+ clientUserName: clientUserName,
+ directory: directory
+ )
+ } catch {
+ await cleanUp(directory: directory)
+ throw error
+ }
+ }
+
+ /// The body of `stage`, split out so its caller can clean up after any failure in it.
+ private func assess(
+ from handle: FileHandle,
+ fileName: String,
+ clientUID: uid_t,
+ clientUserName: String,
+ directory: String
+ ) async throws -> InstallerAssessment {
+ let fallback = HelperPreferences.installFallback(forUser: clientUserName)
+ let requiresAuthentication = HelperPreferences.requireAuthenticationForInstalls(forUser: clientUserName)
+
+ let stagedPath = try StagedFile.copy(from: handle, toDirectory: directory, fileName: fileName)
+
+ let facts: InstallerFacts
+ let payloadPath: String
+ let kind: InstallerFacts.Kind
+
+ switch (fileName as NSString).pathExtension.lowercased() {
+ case "dmg":
+ let attached = try await DiskImageInspector.attach(imageAt: stagedPath, under: directory)
+ mountsInProgress[directory] = attached
+
+ let digest = try StagedFile.sha256(ofFileAt: stagedPath)
+
+ // Copy the payload into root-owned staging and let the image go before anything is
+ // inspected. While it stays mounted, everything below is reading something whose author
+ // can still change it — the image's own digest says nothing about where a link inside it
+ // points. What is assessed and what is installed have to be one set of bytes.
+ let copied = try await DiskImageInspector.copyOut(
+ DiskImageInspector.payload(in: attached.mountPoint),
+ into: directory
+ )
+
+ await DiskImageInspector.detach(attached)
+ mountsInProgress[directory] = nil
+
+ switch copied {
+ case .application(let applicationPath):
+ facts = try await DiskImageInspector.facts(
+ forApplicationAt: applicationPath,
+ imageDigest: digest,
+ fileName: fileName
+ )
+ payloadPath = applicationPath
+ kind = .diskImage
+
+ case .package(let packagePath):
+ // An installer package inside a disk image installs like any other package. The digest
+ // stays that of the image, because that is the file the user downloaded and the one an
+ // administrator would hash for a strict-mode entry.
+ var packageFacts = try await PackageInspector.facts(
+ forStagedPackageAt: packagePath,
+ fileName: fileName,
+ expandingInto: (directory as NSString).appendingPathComponent("expanded")
+ )
+ packageFacts.sha256 = digest
+
+ facts = packageFacts
+ payloadPath = packagePath
+ kind = .package
+ }
+
+ case "pkg", "mpkg":
+ facts = try await PackageInspector.facts(
+ forStagedPackageAt: stagedPath,
+ fileName: fileName,
+ expandingInto: (directory as NSString).appendingPathComponent("expanded")
+ )
+ payloadPath = stagedPath
+ kind = .package
+
+ default:
+ throw SupportCompanionErrors.helperConnection("Support Companion can only install .pkg and .dmg files")
+ }
+
+ let allowlist = HelperPreferences.allowedInstallers(forUser: clientUserName)
+ let decision = InstallerPolicy.evaluate(facts, against: allowlist)
+
+ guard let entry = decision.entry else {
+ log(refusalOf: facts, for: clientUserName, reasons: decision.logReasons)
+
+ // Nothing is kept for an installer nobody allowed: there is no token to install it with,
+ // so the copy and any mounted image would only sit there until they expired.
+ await cleanUp(directory: directory)
+
+ return InstallerAssessment(
+ token: nil,
+ // Shortened only now, after the policy has seen every destination.
+ facts: facts.shortenedForDisplay(),
+ isAllowed: false,
+ matchedEntry: nil,
+ matchMode: nil,
+ rejectionReasons: decision.reasons,
+ fallback: fallback,
+ requiresAuthentication: requiresAuthentication
+ )
+ }
+
+ let token = UUID().uuidString
+
+ staged[token] = Staged(
+ directory: directory,
+ payloadPath: payloadPath,
+ kind: kind,
+ mount: mountsInProgress.removeValue(forKey: directory),
+ facts: facts,
+ entryName: entry.name,
+ matchMode: entry.mode,
+ clientUID: clientUID,
+ createdAt: Date()
+ )
+
+ log(approvalOf: facts, for: clientUserName, entry: entry)
+
+ return InstallerAssessment(
+ token: token,
+ facts: facts.shortenedForDisplay(),
+ isAllowed: true,
+ matchedEntry: entry.name,
+ matchMode: entry.mode,
+ rejectionReasons: [],
+ fallback: fallback,
+ requiresAuthentication: requiresAuthentication
+ )
+ }
+
+ // MARK: Installing
+
+ /// Install what was staged under `token`.
+ func install(token: String, clientUID: uid_t, clientUserName: String) async throws -> String {
+ await expireStale()
+
+ guard let entry = staged[token] else {
+ throw SupportCompanionErrors.helperConnection("That installer is no longer ready. Open it again.")
+ }
+
+ // The token came back from this helper, but only to one connection. Another user logged in at
+ // the same time must not be able to install something the first one staged.
+ guard entry.clientUID == clientUID else {
+ Logger.shared.logError("Refusing to install \(entry.facts.fileName): staged for uid \(entry.clientUID), asked for by uid \(clientUID)")
+ throw SupportCompanionErrors.helperConnection("That installer was prepared for a different user")
+ }
+
+ guard HelperPreferences.enableUserInstalls(forUser: clientUserName) else {
+ throw SupportCompanionErrors.helperConnection("Installing applications from Support Companion is not enabled by an administrator")
+ }
+
+ guard !isInstalling else {
+ throw SupportCompanionErrors.helperConnection("Another installation is already running")
+ }
+
+ isInstalling = true
+
+ // Not a `defer`: the staged copy holds a mounted disk image, and detaching it has to be
+ // awaited before the directory it is mounted inside can be removed. A token is good for one
+ // install either way, so it goes whether or not the install worked.
+ do {
+ // Checked here rather than during assessment: what is on disk at the destination can
+ // change between the two, and this is the last moment before root writes anything.
+ try refuseIfDestinationsAreRedirected(entry.facts.payloadRoots)
+
+ let output: String
+
+ switch entry.kind {
+ case .package:
+ output = try await ExecutionService.run("/usr/sbin/installer", ["-pkg", entry.payloadPath, "-target", "/"])
+ case .diskImage:
+ output = try await installApplication(at: entry.payloadPath, approved: entry.facts, pinnedByDigest: entry.matchMode == .strict)
+ }
+
+ HelperState.appendLine(
+ "installed user=\(clientUserName) file=\(entry.facts.fileName) entry=\(entry.entryName) version=\(entry.facts.version ?? "?") sha256=\(entry.facts.sha256)",
+ toLogAt: Self.auditLogPath
+ )
+
+ isInstalling = false
+ await discard(token)
+
+ return output
+ } catch {
+ HelperState.appendLine(
+ "failed user=\(clientUserName) file=\(entry.facts.fileName) entry=\(entry.entryName) error=\(error.localizedDescription)",
+ toLogAt: Self.auditLogPath
+ )
+
+ isInstalling = false
+ await discard(token)
+
+ throw error
+ }
+ }
+
+ /// Copy a verified application into `/Applications`.
+ ///
+ /// Nothing of the vendor's runs here — it is a copy — but it is a copy made by root into a
+ /// directory every account can see, so the name is checked, the previous version is kept until the
+ /// new one is in place, and the result is verified where it landed before the old one is let go.
+ private func installApplication(at path: String, approved: InstallerFacts, pinnedByDigest: Bool) async throws -> String {
+ let name = (path as NSString).lastPathComponent
+
+ guard !name.isEmpty, !name.hasPrefix("."), !name.contains("/"), name.hasSuffix(".app") else {
+ throw SupportCompanionErrors.helperConnection("'\(name)' is not a name this can install")
+ }
+
+ let destination = "/Applications/\(name)"
+
+ // The name comes from the disk image, so it decides what gets replaced. Replacing one
+ // application with a different one that merely happens to be allowed is not an install, it is
+ // a substitution — and on a shared Mac it changes what somebody else launches.
+ if FileManager.default.fileExists(atPath: destination) {
+ // Read without requiring validity: an unsigned or broken incumbent would otherwise report
+ // no identifier, the comparison would be skipped, and the replacement would go ahead —
+ // which is the case this check exists for.
+ let existingIdentifier = DiskImageInspector.identifier(ofApplicationAt: destination)
+ let incoming = approved.identifiers.first
+
+ guard let existingIdentifier, let incoming else {
+ throw SupportCompanionErrors.helperConnection(
+ "\(name) is already installed and could not be identified, so it has been left alone."
+ )
+ }
+
+ guard existingIdentifier == incoming else {
+ throw SupportCompanionErrors.helperConnection(
+ "\(name) is already installed and is a different application (\(existingIdentifier)). It has been left alone."
+ )
+ }
+ }
+
+ // Nothing is moved or copied while the application is running: `ditto` would replace a bundle
+ // out from under a live process, and if the verification below then fails, the rollback would
+ // put the old one back underneath something that has been running out of a half-replaced one.
+ if isRunning(applicationAt: destination) {
+ throw SupportCompanionErrors.helperConnection(
+ "\(name.replacingOccurrences(of: ".app", with: "")) is open. Quit it and try again."
+ )
+ }
+
+ // Kept on the same volume so the move is a rename and cannot half-happen.
+ let previous = "/Applications/.\(name).supportcompanion-previous"
+ StagedFile.remove(previous)
+
+ let hadPrevious = FileManager.default.fileExists(atPath: destination)
+
+ if hadPrevious {
+ try FileManager.default.moveItem(atPath: destination, toPath: previous)
+ }
+
+ do {
+ _ = try await ExecutionService.run("/usr/bin/ditto", [path, destination])
+
+ // Ownership follows the disk image otherwise, which is whatever the vendor built it as.
+ _ = try await ExecutionService.run("/usr/sbin/chown", ["-R", "root:admin", destination])
+
+ // The signature was checked on the image and is checked again below, so Gatekeeper has
+ // nothing left to ask the user on first launch. Leaving the attribute on would produce a
+ // "downloaded from the internet" prompt for something an administrator allowed.
+ _ = try? await ExecutionService.run("/usr/bin/xattr", ["-d", "-r", "com.apple.quarantine", destination])
+
+ // Not merely "is it signed". The copy has to be the application that was assessed:
+ // anything validly signed satisfies `anchor apple generic`, including an application the
+ // allowlist has never heard of, which would make this check the opposite of a safeguard.
+ let verified = await DiskImageInspector.signature(ofApplicationAt: destination)
+
+ guard verified.trusted else {
+ throw SupportCompanionErrors.helperConnection("The copy in /Applications does not verify, so it has been removed")
+ }
+
+ // `==` alone would be satisfied by both being nil. That is only reachable in strict
+ // mode, where the digest pins the image — but it is sound because of a rule enforced in
+ // another file, so it is stated here rather than relied upon quietly.
+ guard approved.teamID != nil || pinnedByDigest else {
+ throw SupportCompanionErrors.helperConnection(
+ "The approved application has no signing team and was not pinned by digest, so the copy cannot be confirmed. It has been removed."
+ )
+ }
+
+ guard verified.teamID == approved.teamID else {
+ throw SupportCompanionErrors.helperConnection(
+ "The copy in /Applications is signed by \(verified.teamID ?? "nobody"), not \(approved.teamID ?? "the approved team"). It has been removed."
+ )
+ }
+
+ guard let installedIdentifier = verified.bundleIdentifier,
+ approved.identifiers.contains(installedIdentifier) else {
+ throw SupportCompanionErrors.helperConnection(
+ "The copy in /Applications identifies as \(verified.bundleIdentifier ?? "nothing"), which is not what was approved. It has been removed."
+ )
+ }
+ } catch {
+ StagedFile.remove(destination)
+
+ if hadPrevious {
+ try? FileManager.default.moveItem(atPath: previous, toPath: destination)
+ }
+
+ throw error
+ }
+
+ StagedFile.remove(previous)
+
+ return "Installed \(name) in /Applications"
+ }
+
+ /// Refuse when something already on disk would send the install somewhere else.
+ ///
+ /// `installer` writes *through* a symbolic link standing at a destination path — a link at
+ /// `/Applications/Foo.app` pointing at `/tmp/escape` puts the package's payload in `/tmp/escape`,
+ /// with no complaint and a successful exit. Nothing in the package says so, so no amount of
+ /// inspecting it helps.
+ ///
+ /// That matters here more than it would elsewhere, because this app also hands out temporary
+ /// administrator rights: somebody can plant the link while elevated, let the window close, and
+ /// then use an allowlisted installer to write wherever the link points, as root.
+ ///
+ /// `/etc`, `/var` and `/tmp` are links macOS ships with and are left alone.
+ private func refuseIfDestinationsAreRedirected(_ roots: [String]) throws {
+ let systemLinks = ["/etc", "/var", "/tmp"]
+
+ for root in roots {
+ var current = ""
+
+ for component in (root as NSString).pathComponents where component != "/" {
+ current += "/" + component
+
+ var info = stat()
+
+ // Nothing there yet, so nothing below it either.
+ guard lstat(current, &info) == 0 else { break }
+
+ guard (info.st_mode & S_IFMT) == S_IFLNK else { continue }
+ guard !systemLinks.contains(current) else { continue }
+
+ let target = (try? FileManager.default.destinationOfSymbolicLink(atPath: current)) ?? "somewhere else"
+
+ Logger.shared.logError("Refusing to install: \(current) is a link to \(target)")
+
+ throw SupportCompanionErrors.helperConnection(
+ "\(current) is a link to \(target), so installing would write somewhere other than where this package says. Nothing has been installed."
+ )
+ }
+ }
+ }
+
+ /// Whether the application at `path` is running.
+ ///
+ /// Asked of the kernel rather than of `pgrep -f`, which takes a POSIX regular expression. The
+ /// bundle name comes out of a disk image somebody else authored, and escaping it for the wrong
+ /// flavour of regex fails in the unsafe direction: an escape the matcher does not recognise means
+ /// no match, which reads as "not running", and the copy goes over a live application.
+ private func isRunning(applicationAt path: String) -> Bool {
+ let prefix = path + "/Contents/MacOS/"
+
+ var count = proc_listallpids(nil, 0)
+ guard count > 0 else { return false }
+
+ // Room for processes started between asking the size and asking for the list.
+ count += 64
+
+ var pids = [pid_t](repeating: 0, count: Int(count))
+ let bytes = proc_listallpids(&pids, Int32(MemoryLayout.size) * count)
+ guard bytes > 0 else { return false }
+
+ let found = Int(bytes) / MemoryLayout.size
+
+ for pid in pids.prefix(found) where pid > 0 {
+ // PROC_PIDPATHINFO_MAXSIZE, which Darwin does not surface to Swift: 4 * MAXPATHLEN.
+ var buffer = [CChar](repeating: 0, count: 4 * 1024)
+
+ guard proc_pidpath(pid, &buffer, UInt32(buffer.count)) > 0 else { continue }
+
+ if String(cString: buffer).hasPrefix(prefix) { return true }
+ }
+
+ return false
+ }
+
+ // MARK: Lifetime
+
+ /// Detach anything mounted under a staging directory, then remove it.
+ ///
+ /// In that order: the directory holds the mount point, so removing it first would either fail or
+ /// leave the image attached with nothing pointing at it.
+ private func cleanUp(directory: String, mount: DiskImageInspector.Mount? = nil) async {
+ if let mount = mount ?? mountsInProgress.removeValue(forKey: directory) {
+ await DiskImageInspector.detach(mount)
+ }
+
+ StagedFile.remove(directory)
+ }
+
+ func discard(_ token: String) async {
+ guard let entry = staged.removeValue(forKey: token) else { return }
+ await cleanUp(directory: entry.directory, mount: entry.mount)
+ }
+
+ func discard(_ token: String, clientUID: uid_t) async {
+ guard let entry = staged[token], entry.clientUID == clientUID else { return }
+ await discard(token)
+ }
+
+ private func expireStale() async {
+ let cutoff = Date().addingTimeInterval(-Self.stagingLifetime)
+
+ for (token, entry) in staged where entry.createdAt < cutoff {
+ Logger.shared.logDebug("Discarding staged installer \(entry.facts.fileName): nothing decided within \(Int(Self.stagingLifetime))s")
+ await discard(token)
+ }
+ }
+
+ // MARK: Audit
+
+ private func log(approvalOf facts: InstallerFacts, for userName: String, entry: AllowedInstaller) {
+ HelperState.appendLine(
+ "allowed user=\(userName) file=\(facts.fileName) kind=\(facts.kind.rawValue) id=\(facts.identifiers.joined(separator: ",")) version=\(facts.version ?? "?") team=\(facts.teamID ?? "?") notarized=\(facts.notarized) sha256=\(facts.sha256) paths=\(facts.payloadRoots.joined(separator: ",")) entry=\(entry.name) mode=\(entry.mode.rawValue)",
+ toLogAt: Self.auditLogPath
+ )
+ }
+
+ private func log(refusalOf facts: InstallerFacts, for userName: String, reasons: [String]) {
+ HelperState.appendLine(
+ "refused user=\(userName) file=\(facts.fileName) kind=\(facts.kind.rawValue) id=\(facts.identifiers.joined(separator: ",")) version=\(facts.version ?? "?") team=\(facts.teamID ?? "?") sha256=\(facts.sha256) paths=\(facts.payloadRoots.joined(separator: ",")) reasons=\(reasons.joined(separator: "; "))",
+ toLogAt: Self.auditLogPath
+ )
+ }
+}
diff --git a/Helper/InstallStaging.swift b/Helper/InstallStaging.swift
new file mode 100644
index 0000000..cadfb95
--- /dev/null
+++ b/Helper/InstallStaging.swift
@@ -0,0 +1,184 @@
+//
+// InstallStaging.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import CryptoKit
+import Foundation
+
+// MARK: - Helper state
+
+/// The root-owned directory the helper keeps its own state in.
+///
+/// `/Library/Application Support` is no good for any of it: the app creates its folder there as the
+/// logged-in user, and whoever owns a directory can replace what is in it. `/var/db` is root-owned and
+/// is where daemon state belongs. `ElevationCoordinator` keeps its deadline and audit log under the
+/// same directory for the same reason.
+enum HelperState {
+
+ static let directory = "/var/db/com.github.macadmins.SupportCompanion"
+
+ /// Create a directory that only root can read or write, including every level above it.
+ ///
+ /// Each level is created separately rather than with `withIntermediateDirectories`, so the mode is
+ /// applied to all of them and not only to the last one.
+ static func makeDirectory(at path: String, permissions: Int16 = 0o700) throws {
+ let components = (path as NSString).pathComponents
+ var current = "/"
+
+ for component in components where component != "/" {
+ current = (current as NSString).appendingPathComponent(component)
+
+ if FileManager.default.fileExists(atPath: current) { continue }
+
+ try FileManager.default.createDirectory(
+ atPath: current,
+ withIntermediateDirectories: false,
+ attributes: [.posixPermissions: permissions, .ownerAccountID: 0, .groupOwnerAccountID: 0]
+ )
+ }
+ }
+
+ /// Append one line to a root-owned log, flattening anything that came from outside first.
+ ///
+ /// One line per event is the only structure these logs have, so text containing a newline would let
+ /// whoever supplied it write entries of their choosing into a root-owned record.
+ static func appendLine(_ line: String, toLogAt path: String) {
+ try? makeDirectory(at: directory)
+
+ let flattened = line.unicodeScalars
+ .map { CharacterSet.controlCharacters.contains($0) ? " " : Character($0) }
+ .reduce(into: "") { $0.append($1) }
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .prefix(2048)
+
+ let stamped = "\(ISO8601DateFormatter().string(from: Date())) \(flattened)\n"
+ guard let data = stamped.data(using: .utf8) else { return }
+
+ if let handle = FileHandle(forWritingAtPath: path) {
+ defer { try? handle.close() }
+ try? handle.seekToEnd()
+ try? handle.write(contentsOf: data)
+ } else {
+ FileManager.default.createFile(
+ atPath: path,
+ contents: data,
+ attributes: [.posixPermissions: 0o600, .ownerAccountID: 0]
+ )
+ }
+ }
+}
+
+// MARK: - Staged file
+
+/// Copies an installer out of the user's reach, and measures it once it is there.
+enum StagedFile {
+
+ /// The staging directories, one per installer being considered.
+ static let root = HelperState.directory + "/installs"
+
+ /// The largest installer the helper will copy.
+ ///
+ /// Staging means a second copy on disk, and a standard user handing the helper a very large file is
+ /// otherwise a way to fill the boot volume as root.
+ static let maximumBytes: Int64 = 8 * 1024 * 1024 * 1024
+
+ private static let chunkSize = 4 * 1024 * 1024
+
+ /// A private directory for one candidate installer.
+ static func makeStagingDirectory() throws -> String {
+ let path = (root as NSString).appendingPathComponent(UUID().uuidString)
+ try HelperState.makeDirectory(at: path)
+ return path
+ }
+
+ static func remove(_ path: String) {
+ try? FileManager.default.removeItem(atPath: path)
+ }
+
+ /// Copy what the descriptor refers to into the staging directory.
+ ///
+ /// The app opens the file and passes the descriptor rather than the path, for two reasons. The app
+ /// runs as the user, so it can only open what the user could already read — a path would let a
+ /// standard user have root read and measure files they have no access to. And a descriptor names
+ /// the file itself rather than a name that can be repointed, so the copy is of what the user
+ /// actually double-clicked.
+ static func copy(from handle: FileHandle, toDirectory directory: String, fileName: String) throws -> String {
+ var info = stat()
+
+ guard fstat(handle.fileDescriptor, &info) == 0 else {
+ throw SupportCompanionErrors.helperConnection("Unable to read the installer's file information")
+ }
+
+ guard (info.st_mode & S_IFMT) == S_IFREG else {
+ throw SupportCompanionErrors.helperConnection("The installer is not a regular file")
+ }
+
+ guard info.st_size <= maximumBytes else {
+ throw SupportCompanionErrors.helperConnection(
+ "The installer is larger than \(maximumBytes / (1024 * 1024 * 1024))GB"
+ )
+ }
+
+ guard try hasRoom(for: info.st_size) else {
+ throw SupportCompanionErrors.helperConnection("There is not enough free space to check this installer")
+ }
+
+ // Only the extension is taken from the name the client supplied; the rest is ours. A name is
+ // the one part of this a client chooses freely, and it is about to become a path we run
+ // commands against.
+ let fileExtension = (fileName as NSString).pathExtension.lowercased()
+ let safeExtension = fileExtension.allSatisfy(\.isLetter) && !fileExtension.isEmpty ? fileExtension : "pkg"
+ let destination = (directory as NSString).appendingPathComponent("installer.\(safeExtension)")
+
+ guard FileManager.default.createFile(
+ atPath: destination,
+ contents: nil,
+ attributes: [.posixPermissions: 0o600, .ownerAccountID: 0]
+ ) else {
+ throw SupportCompanionErrors.helperConnection("Unable to create a staging file for the installer")
+ }
+
+ guard let output = FileHandle(forWritingAtPath: destination) else {
+ throw SupportCompanionErrors.helperConnection("Unable to open the staging file for the installer")
+ }
+
+ defer { try? output.close() }
+
+ try handle.seek(toOffset: 0)
+
+ while true {
+ guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break }
+ try output.write(contentsOf: chunk)
+ }
+
+ return destination
+ }
+
+ private static func hasRoom(for bytes: Int64) throws -> Bool {
+ let values = try URL(fileURLWithPath: "/var/db").resourceValues(forKeys: [.volumeAvailableCapacityKey])
+
+ guard let available = values.volumeAvailableCapacity else { return true }
+
+ // Twice over: the staged copy, and then whatever it installs.
+ return Int64(available) > bytes * 2
+ }
+
+ /// The SHA-256 of a staged file, read in chunks so a large installer is not held in memory.
+ static func sha256(ofFileAt path: String) throws -> String {
+ guard let handle = FileHandle(forReadingAtPath: path) else {
+ throw SupportCompanionErrors.helperConnection("Unable to read the staged installer")
+ }
+
+ defer { try? handle.close() }
+
+ var hasher = SHA256()
+
+ while true {
+ guard let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty else { break }
+ hasher.update(data: chunk)
+ }
+
+ return hasher.finalize().map { String(format: "%02x", $0) }.joined()
+ }
+}
diff --git a/Helper/InstallerPolicy.swift b/Helper/InstallerPolicy.swift
new file mode 100644
index 0000000..d28778f
--- /dev/null
+++ b/Helper/InstallerPolicy.swift
@@ -0,0 +1,761 @@
+//
+// InstallerPolicy.swift
+// SupportCompanion
+//
+// Shared by the app and the privileged helper.
+//
+
+import Foundation
+
+// MARK: - Match mode
+
+/// How an allowlist entry recognises the installer the administrator meant.
+public enum InstallerMatchMode: String, Codable, Sendable {
+ /// The file's SHA-256 must be exactly the one in the profile. Pins one build; survives no update.
+ case strict
+ /// The signature must be the organisation's: a Team ID, and normally an identifier as well.
+ case signature
+}
+
+// MARK: - Allowlist entry
+
+/// One application an administrator has allowed a standard user to install.
+///
+/// Parsed from the `AllowedInstallers` preference, which is only ever read from a root-owned file. See
+/// `HelperPreferences`.
+public struct AllowedInstaller: Sendable {
+
+ public let name: String
+
+ /// The Developer ID team the installer must be signed by. Required in `.signature` mode.
+ public let teamID: String?
+
+ /// Package identifiers this entry accepts, for a `.pkg`.
+ public let packageIdentifiers: [String]
+
+ /// Bundle identifiers this entry accepts, for the app inside a `.dmg`.
+ public let bundleIdentifiers: [String]
+
+ /// The exact digest of the installer file, for `.strict` mode.
+ public let sha256: String?
+
+ /// The SHA-256 of the signing certificate itself, as an optional extra pin.
+ ///
+ /// A Team ID is read out of a certificate's subject, which is a human-readable string; this is the
+ /// certificate. Stricter than `TeamID` and unambiguous in a way a name is not — at the cost of
+ /// needing an update whenever the vendor renews, which is why it is never required.
+ public let leafCertificateSHA256: String?
+
+ /// Refuse anything older than this, so a signed-but-vulnerable build cannot be installed instead.
+ public let minimumVersion: String?
+
+ public let requireNotarized: Bool
+
+ /// Whether a package carrying pre/postinstall scripts is acceptable.
+ ///
+ /// Off by default, and the single most useful restriction here: a scriptless package whose payload
+ /// lands in `/Applications` is a file copy, while one with a postinstall script is arbitrary code as
+ /// root on every future build the vendor signs.
+ public let allowScripts: Bool
+
+ /// Where this installer is allowed to write, as absolute path prefixes.
+ ///
+ /// `nil` leaves it unrestricted, which is the default because enumerating paths for every app is
+ /// more than most administrators will do. Setting it is what bounds the damage when an entry is
+ /// written loosely: an allowlisted package is otherwise free to drop a LaunchDaemon while
+ /// installing the app that was actually approved.
+ public let allowedPayloadPrefixes: [String]?
+
+ /// Let this installer write anywhere, including the places `InstallerPolicy.protectedPrefixes`
+ /// otherwise keeps back.
+ ///
+ /// Its own key rather than `AllowedPayloadPrefixes: ["/"]`, so the most dangerous setting is one
+ /// somebody has to mean, not one they can reach by typing a prefix wrong.
+ public let allowUnrestrictedPayload: Bool
+
+ /// Accept anything signed by `teamID`, whatever it identifies itself as.
+ ///
+ /// Spelled out rather than inferred from a missing identifier, because the two are very different
+ /// intentions and only one of them should be reachable by leaving a key out of a profile by mistake.
+ /// This is a vendor allowlist: it covers every installer that team will ever sign, not one app.
+ public let allowAnyIdentifier: Bool
+
+ public var mode: InstallerMatchMode { sha256 == nil ? .signature : .strict }
+
+ public init(
+ name: String,
+ teamID: String?,
+ packageIdentifiers: [String],
+ bundleIdentifiers: [String],
+ sha256: String?,
+ leafCertificateSHA256: String? = nil,
+ minimumVersion: String?,
+ requireNotarized: Bool,
+ allowScripts: Bool,
+ allowAnyIdentifier: Bool,
+ allowedPayloadPrefixes: [String]? = nil,
+ allowUnrestrictedPayload: Bool = false
+ ) {
+ self.name = name
+ self.teamID = teamID
+ self.packageIdentifiers = packageIdentifiers
+ self.bundleIdentifiers = bundleIdentifiers
+ self.sha256 = sha256
+ self.leafCertificateSHA256 = leafCertificateSHA256
+ self.minimumVersion = minimumVersion
+ self.requireNotarized = requireNotarized
+ self.allowScripts = allowScripts
+ self.allowAnyIdentifier = allowAnyIdentifier
+ self.allowedPayloadPrefixes = allowedPayloadPrefixes
+ self.allowUnrestrictedPayload = allowUnrestrictedPayload
+ }
+}
+
+// MARK: - Parsing
+
+extension AllowedInstaller {
+
+ /// Build an entry from one dictionary in the `AllowedInstallers` array.
+ ///
+ /// Returns `nil` for an entry that cannot decide anything, rather than one that decides too much:
+ /// an entry with no `SHA256` and no `TeamID` would otherwise match on nothing at all.
+ public static func make(from dictionary: [String: Any]) -> (
+ entry: AllowedInstaller?, problem: String?
+ ) {
+ let name = (dictionary["Name"] as? String)?.trimmed ?? ""
+ let label = name.isEmpty ? "" : name
+
+ guard !name.isEmpty else {
+ return (nil, "an entry has no Name")
+ }
+
+ let sha256 = (dictionary["SHA256"] as? String)?.trimmed.lowercased()
+ let teamID = (dictionary["TeamID"] as? String)?.trimmed
+
+ if let sha256, sha256.count != 64 || !sha256.allSatisfy(\.isHexDigit) {
+ return (nil, "'\(label)' has a SHA256 that is not a 64-character hex digest")
+ }
+
+ let leafCertificateSHA256 = (dictionary["LeafCertificateSHA256"] as? String)?
+ .replacingOccurrences(of: " ", with: "")
+ .trimmed
+ .lowercased()
+
+ if let leafCertificateSHA256,
+ leafCertificateSHA256.count != 64 || !leafCertificateSHA256.allSatisfy(\.isHexDigit) {
+ return (nil, "'\(label)' has a LeafCertificateSHA256 that is not a 64-character hex digest")
+ }
+
+ let packageIdentifiers = stringList(dictionary["PackageIdentifier"])
+ let bundleIdentifiers = stringList(dictionary["BundleIdentifier"])
+ let allowAnyIdentifier = boolean(dictionary["AllowAnyIdentifier"], default: false)
+
+ if sha256 == nil {
+ guard let teamID, !teamID.isEmpty else {
+ return (nil, "'\(label)' has neither a SHA256 nor a TeamID, so it can never match")
+ }
+
+ guard allowAnyIdentifier || !packageIdentifiers.isEmpty || !bundleIdentifiers.isEmpty
+ else {
+ return (
+ nil,
+ "'\(label)' has a TeamID but no PackageIdentifier or BundleIdentifier. Add one, or set AllowAnyIdentifier to allow everything that team signs"
+ )
+ }
+ }
+
+ return (
+ AllowedInstaller(
+ name: name,
+ teamID: teamID?.isEmpty == true ? nil : teamID,
+ packageIdentifiers: packageIdentifiers,
+ bundleIdentifiers: bundleIdentifiers,
+ sha256: sha256,
+ leafCertificateSHA256: leafCertificateSHA256,
+ minimumVersion: (dictionary["MinimumVersion"] as? String)?.trimmed,
+ requireNotarized: boolean(dictionary["RequireNotarized"], default: true),
+ allowScripts: boolean(dictionary["AllowScripts"], default: false),
+ allowAnyIdentifier: allowAnyIdentifier,
+ allowedPayloadPrefixes: dictionary["AllowedPayloadPrefixes"].map(stringList),
+ allowUnrestrictedPayload: boolean(dictionary["AllowUnrestrictedPayload"], default: false)
+ ),
+ nil
+ )
+ }
+
+ private static func stringList(_ value: Any?) -> [String] {
+ if let single = value as? String {
+ let trimmed = single.trimmed
+ return trimmed.isEmpty ? [] : [trimmed]
+ }
+
+ if let many = value as? [String] {
+ return many.map(\.trimmed).filter { !$0.isEmpty }
+ }
+
+ return []
+ }
+
+ private static func boolean(_ value: Any?, default defaultValue: Bool) -> Bool {
+ (value as? Bool) ?? (value as? NSNumber)?.boolValue ?? defaultValue
+ }
+}
+
+// MARK: - Facts
+
+/// What the helper found in the installer it staged, before any policy is applied.
+public struct InstallerFacts: Codable, Sendable, Equatable {
+
+ public enum Kind: String, Codable, Sendable {
+ case package
+ case diskImage
+ }
+
+ public var kind: Kind
+ public var fileName: String
+ public var sha256: String
+
+ /// The Team ID from the signing certificate's organisational unit.
+ public var teamID: String?
+
+ /// The leaf certificate's common name, for display: "Developer ID Installer: Google, Inc. (EQHXZ8M8AV)".
+ public var authority: String?
+
+ /// The SHA-256 of the signing certificate, when one could be read.
+ public var leafCertificateSHA256: String?
+
+ /// Whether the signature is present and chains to a certificate the system trusts.
+ public var signatureTrusted: Bool
+
+ /// Whether Gatekeeper reports the installer as notarized.
+ public var notarized: Bool
+
+ /// Package identifiers for a `.pkg`, or the app's bundle identifier for a `.dmg`.
+ public var identifiers: [String]
+
+ public var displayName: String?
+ public var version: String?
+
+ /// Whether any component of a package carries pre/postinstall scripts.
+ public var hasScripts: Bool
+
+ /// What kind of scripts were found, for the sheet and the log.
+ public var scriptSummary: String?
+
+ /// The most destinations described to the client, purely so the window has something readable.
+ ///
+ /// Separate from the limit `PackageInspector` enforces, and applied only after the policy has
+ /// seen every one of them: a shortened list is a display convenience, never the thing a decision
+ /// was made from.
+ public static let displayedPayloadRoots = 12
+
+ /// The places this installer writes, shortened to the shallowest path that contains each one.
+ ///
+ /// Derived by truncating every file and symlink in the package's bill of materials, so checking
+ /// these covers everything underneath them. For a disk image it is simply where the application
+ /// lands.
+ public var payloadRoots: [String]
+
+ /// Whether a distribution package pulls a component from a URL at install time.
+ ///
+ /// Always disqualifying, and not configurable: those bytes arrive after everything here has run, so
+ /// nothing about them was ever checked.
+ public var hasRemoteReferences: Bool
+
+ /// Decoded field by field, with a default for everything the sender might not have sent.
+ ///
+ /// The app and the helper are deployed separately — with `SkipHelperInstall` the helper arrives
+ /// from an MDM on its own schedule — so at any moment one of them may be a version behind. A
+ /// synthesised decoder makes every added field a breaking change across that gap, and the failure
+ /// surfaces to the user as "The data couldn't be read because it is missing", which tells nobody
+ /// anything. Every default here is the cautious reading: absent never means more permissive.
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ kind = try container.decode(Kind.self, forKey: .kind)
+ fileName = try container.decodeIfPresent(String.self, forKey: .fileName) ?? ""
+ sha256 = try container.decodeIfPresent(String.self, forKey: .sha256) ?? ""
+ teamID = try container.decodeIfPresent(String.self, forKey: .teamID)
+ authority = try container.decodeIfPresent(String.self, forKey: .authority)
+ leafCertificateSHA256 = try container.decodeIfPresent(String.self, forKey: .leafCertificateSHA256)
+ signatureTrusted =
+ try container.decodeIfPresent(Bool.self, forKey: .signatureTrusted) ?? false
+ notarized = try container.decodeIfPresent(Bool.self, forKey: .notarized) ?? false
+ identifiers = try container.decodeIfPresent([String].self, forKey: .identifiers) ?? []
+ displayName = try container.decodeIfPresent(String.self, forKey: .displayName)
+ version = try container.decodeIfPresent(String.self, forKey: .version)
+ hasScripts = try container.decodeIfPresent(Bool.self, forKey: .hasScripts) ?? false
+ scriptSummary = try container.decodeIfPresent(String.self, forKey: .scriptSummary)
+ hasRemoteReferences =
+ try container.decodeIfPresent(Bool.self, forKey: .hasRemoteReferences) ?? false
+ payloadRoots = try container.decodeIfPresent([String].self, forKey: .payloadRoots) ?? []
+ }
+
+ public init(
+ kind: Kind,
+ fileName: String,
+ sha256: String,
+ teamID: String? = nil,
+ authority: String? = nil,
+ leafCertificateSHA256: String? = nil,
+ signatureTrusted: Bool = false,
+ notarized: Bool = false,
+ identifiers: [String] = [],
+ displayName: String? = nil,
+ version: String? = nil,
+ hasScripts: Bool = false,
+ scriptSummary: String? = nil,
+ hasRemoteReferences: Bool = false,
+ payloadRoots: [String] = []
+ ) {
+ self.kind = kind
+ self.fileName = fileName
+ self.sha256 = sha256
+ self.teamID = teamID
+ self.authority = authority
+ self.leafCertificateSHA256 = leafCertificateSHA256
+ self.signatureTrusted = signatureTrusted
+ self.notarized = notarized
+ self.identifiers = identifiers
+ self.displayName = displayName
+ self.version = version
+ self.hasScripts = hasScripts
+ self.scriptSummary = scriptSummary
+ self.hasRemoteReferences = hasRemoteReferences
+ self.payloadRoots = payloadRoots
+ }
+}
+
+public extension InstallerFacts {
+
+ /// A copy safe to hand to the client, with the destination list shortened for the window.
+ ///
+ /// Called only once the policy has run against the full set.
+ func shortenedForDisplay() -> InstallerFacts {
+ guard payloadRoots.count > InstallerFacts.displayedPayloadRoots else { return self }
+
+ var copy = self
+ let shown = payloadRoots.prefix(InstallerFacts.displayedPayloadRoots)
+ copy.payloadRoots = shown + ["and \(payloadRoots.count - shown.count) more"]
+
+ return copy
+ }
+}
+
+// MARK: - Assessment
+
+/// What the helper decided about a staged installer, and what the app may do about it.
+///
+/// Crosses the connection as JSON so the app never has to parse an installer or read the allowlist to
+/// draw its sheet. The app's own view of policy decides nothing; this is the decision.
+public struct InstallerAssessment: Codable, Sendable, Equatable {
+
+ /// What the app should offer when the installer is not allowed.
+ public enum Fallback: String, Codable, Sendable {
+ /// Hand the file to Installer.app or Finder, exactly as a double-click would behave without us.
+ case installer
+ /// Offer the existing time-limited elevation flow instead.
+ case elevate
+ /// Offer nothing.
+ case none
+ }
+
+ /// Names the staged copy this assessment was made from. Present only when the install may proceed.
+ public var token: String?
+
+ public var facts: InstallerFacts
+ public var isAllowed: Bool
+
+ /// The `Name` of the allowlist entry that matched.
+ public var matchedEntry: String?
+ public var matchMode: InstallerMatchMode?
+
+ /// Why it did not match, in the admin's terms, for the sheet and the log.
+ public var rejectionReasons: [String]
+
+ public var fallback: Fallback
+
+ /// Whether the app must authenticate the user before asking for the install.
+ public var requiresAuthentication: Bool
+
+ /// As with `InstallerFacts`, tolerant of a sender that is a version behind. An assessment missing
+ /// its verdict decodes as refused, and one missing its authentication setting as requiring it.
+ public init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+
+ token = try container.decodeIfPresent(String.self, forKey: .token)
+ facts = try container.decode(InstallerFacts.self, forKey: .facts)
+ isAllowed = try container.decodeIfPresent(Bool.self, forKey: .isAllowed) ?? false
+ matchedEntry = try container.decodeIfPresent(String.self, forKey: .matchedEntry)
+ matchMode = try container.decodeIfPresent(InstallerMatchMode.self, forKey: .matchMode)
+ rejectionReasons =
+ try container.decodeIfPresent([String].self, forKey: .rejectionReasons) ?? []
+ fallback = try container.decodeIfPresent(Fallback.self, forKey: .fallback) ?? .installer
+ requiresAuthentication =
+ try container.decodeIfPresent(Bool.self, forKey: .requiresAuthentication) ?? true
+ }
+
+ public init(
+ token: String?,
+ facts: InstallerFacts,
+ isAllowed: Bool,
+ matchedEntry: String?,
+ matchMode: InstallerMatchMode?,
+ rejectionReasons: [String],
+ fallback: Fallback,
+ requiresAuthentication: Bool
+ ) {
+ self.token = token
+ self.facts = facts
+ self.isAllowed = isAllowed
+ self.matchedEntry = matchedEntry
+ self.matchMode = matchMode
+ self.rejectionReasons = rejectionReasons
+ self.fallback = fallback
+ self.requiresAuthentication = requiresAuthentication
+ }
+}
+
+// MARK: - JSON
+
+extension InstallerAssessment {
+
+ public func jsonString() throws -> String {
+ let data = try JSONEncoder().encode(self)
+
+ guard let json = String(data: data, encoding: .utf8) else {
+ throw SupportCompanionErrors.invalidStringConversion
+ }
+
+ return json
+ }
+
+ public static func make(fromJSON json: String) throws -> InstallerAssessment {
+ guard let data = json.data(using: .utf8) else {
+ throw SupportCompanionErrors.invalidStringConversion
+ }
+
+ return try JSONDecoder().decode(InstallerAssessment.self, from: data)
+ }
+}
+
+// MARK: - Matching
+
+public enum InstallerPolicy {
+
+ /// Decide whether the facts satisfy any entry in the allowlist.
+ ///
+ /// Every entry is tried, and the one that got furthest supplies the reasons when none of them
+ /// matched — "Chrome is allowed, but only from version 141" is worth saying, while "no entry named
+ /// this" is not, when an entry plainly meant this app.
+ /// - Returns: the entry that allowed it, the reasons safe to show the person at the Mac, and the
+ /// fuller reasons for the root-owned log.
+ ///
+ /// The two sets of reasons differ on purpose. What an administrator allows elsewhere is their
+ /// business, not something to list in a window because somebody opened the wrong installer, so the
+ /// user's copy never names another entry or what it expected. The log is root-owned and is where
+ /// the detail belongs.
+ public static func evaluate(
+ _ facts: InstallerFacts,
+ against allowlist: [AllowedInstaller]
+ ) -> (entry: AllowedInstaller?, reasons: [String], logReasons: [String]) {
+
+ guard !allowlist.isEmpty else {
+ return (nil, [unlisted], ["No applications have been allowed by an administrator"])
+ }
+
+ var closest: (entry: AllowedInstaller, reasons: [String])?
+
+ for entry in allowlist {
+ let reasons = disqualifications(of: facts, for: entry)
+
+ if reasons.isEmpty {
+ return (entry, [], [])
+ }
+
+ // Only entries that are recognisably about *this* installer may explain it. Without this,
+ // the single entry an administrator has configured is always the "closest" one, and a user
+ // installing something unrelated is told their package is the wrong version of Firefox.
+ guard isAbout(facts, entry: entry) else { continue }
+
+ // Fewer objections means the entry was more nearly about this installer. Ties keep the
+ // first, so the order in the profile decides what the user is told.
+ if closest == nil || reasons.count < closest!.reasons.count {
+ closest = (entry, reasons)
+ }
+ }
+
+ guard let closest else {
+ return (nil, [unlisted], [unlistedReason(for: facts)])
+ }
+
+ // The entry is about this installer, so its objections describe the file in front of the user
+ // and can be shown as they are. Only the entry's name is dropped, which tells them nothing
+ // they can act on and names a rule that is not theirs to see.
+ return (
+ nil,
+ closest.reasons.map { $0.prefix(1).capitalized + $0.dropFirst() },
+ closest.reasons.map { "\(closest.entry.name): \($0)" }
+ )
+ }
+
+ /// What the user is told when nothing on the list was written for this installer.
+ public static let unlisted =
+ "This installer is not on your organisation's list of approved software"
+
+ /// Whether an entry is plainly meant for this installer, whatever else it objects to.
+ ///
+ /// The test is identity alone — the digest in strict mode, an identifier in signature mode. An
+ /// entry that matches neither is about some other application, and its objections say nothing
+ /// useful about this one.
+ private static func isAbout(_ facts: InstallerFacts, entry: AllowedInstaller) -> Bool {
+ switch entry.mode {
+ case .strict:
+ return facts.sha256.caseInsensitiveCompare(entry.sha256 ?? "") == .orderedSame
+
+ case .signature:
+ if entry.allowAnyIdentifier {
+ return entry.teamID != nil && entry.teamID == facts.teamID
+ }
+
+ let accepted =
+ facts.kind == .package ? entry.packageIdentifiers : entry.bundleIdentifiers
+ return accepted.contains(where: facts.identifiers.contains)
+ }
+ }
+
+ /// What to say about an installer no entry was written for.
+ ///
+ /// Names what the installer says it is, because that is what has to go into a profile for it to be
+ /// allowed — the person reading this is either forwarding it to an administrator or is one.
+ private static func unlistedReason(for facts: InstallerFacts) -> String {
+ var identity: [String] = []
+
+ if let first = facts.identifiers.first {
+ identity.append(first)
+ }
+
+ if let team = facts.teamID {
+ identity.append("team \(team)")
+ }
+
+ guard !identity.isEmpty else {
+ return "No entry on your organisation's list covers this installer"
+ }
+
+ return
+ "No entry on your organisation's list covers this installer (\(identity.joined(separator: ", ")))"
+ }
+
+ /// Everything about `facts` that stops `entry` from allowing it. Empty means allowed.
+ private static func disqualifications(of facts: InstallerFacts, for entry: AllowedInstaller)
+ -> [String]
+ {
+ var reasons: [String] = []
+
+ switch entry.mode {
+ case .strict:
+ if facts.sha256.caseInsensitiveCompare(entry.sha256 ?? "") != .orderedSame {
+ reasons.append("this file's SHA-256 is not the one allowed")
+ }
+
+ case .signature:
+ if !facts.signatureTrusted {
+ reasons.append("it is not signed by a certificate the system trusts")
+ }
+
+ if let required = entry.teamID {
+ if let found = facts.teamID {
+ if found != required {
+ reasons.append("it is signed by team \(found), not \(required)")
+ }
+ } else {
+ reasons.append("no Team ID could be read from its signature")
+ }
+ }
+
+ if !entry.allowAnyIdentifier {
+ let accepted =
+ facts.kind == .package ? entry.packageIdentifiers : entry.bundleIdentifiers
+
+ if accepted.isEmpty {
+ reasons.append(
+ facts.kind == .package
+ ? "no PackageIdentifier is configured for this kind of installer"
+ : "no BundleIdentifier is configured for this kind of installer"
+ )
+ } else if facts.identifiers.isEmpty {
+ reasons.append("no identifier could be read from it")
+ } else {
+ // Every identifier, not merely one of them. A distribution package installs all of
+ // its components, so accepting it because one component was named would let the
+ // others in unexamined — which is how an approved application arrives with a
+ // LaunchDaemon nobody asked about.
+ let uncovered = facts.identifiers.filter { !accepted.contains($0) }
+
+ if !uncovered.isEmpty {
+ reasons.append(
+ "it also installs \(uncovered.joined(separator: ", ")), which this entry does not allow"
+ )
+ }
+ }
+ }
+ }
+
+ // Applies in both modes. A digest of the file pins these exact bytes; this pins who signed
+ // them, which keeps holding as the vendor ships new versions.
+ if let required = entry.leafCertificateSHA256 {
+ if let found = facts.leafCertificateSHA256 {
+ if found.caseInsensitiveCompare(required) != .orderedSame {
+ reasons.append("it is signed by a different certificate than the one allowed")
+ }
+ } else {
+ reasons.append("no signing certificate could be read from it")
+ }
+ }
+
+ // Applies in both modes: a digest pins the bytes, but the administrator still said notarized.
+ if entry.requireNotarized && !facts.notarized {
+ reasons.append("it is not notarized")
+ }
+
+ if facts.kind == .package && facts.hasScripts && !entry.allowScripts {
+ let what = facts.scriptSummary ?? "install scripts that run as root"
+ reasons.append("it carries \(what), which this entry does not allow")
+ }
+
+ if facts.hasRemoteReferences {
+ reasons.append(
+ "it downloads further packages while installing, which cannot be checked in advance"
+ )
+ }
+
+ // Applies in both modes: a digest pins which bytes arrive, not where they end up.
+ if !entry.allowUnrestrictedPayload {
+ if let prefixes = entry.allowedPayloadPrefixes, !prefixes.isEmpty {
+ let outside = facts.payloadRoots.filter { root in
+ !prefixes.contains { isPath(root, under: $0) }
+ }
+
+ if !outside.isEmpty {
+ reasons.append(
+ "it writes to \(outside.prefix(4).joined(separator: ", ")), which this entry does not allow"
+ )
+ }
+ } else {
+ // Nothing configured means the usual places an application goes — not anywhere at
+ // all. Checked in both directions because `payloadRoots` are truncated towards the
+ // root, so a reported root can sit *above* a protected prefix as easily as inside it.
+ let blocked = facts.payloadRoots.filter { root in
+ protectedPrefixes.contains { isPath(root, under: $0) || isPath($0, under: root) }
+ }
+
+ if !blocked.isEmpty {
+ reasons.append(
+ "it writes to \(blocked.prefix(4).joined(separator: ", ")), which needs AllowedPayloadPrefixes to permit it"
+ )
+ }
+ }
+ }
+
+ if let minimum = entry.minimumVersion {
+ if let version = facts.version {
+ if isVersion(version, olderThan: minimum) {
+ reasons.append(
+ "version \(version) is older than the allowed minimum \(minimum)")
+ }
+ } else {
+ reasons.append("no version could be read, and a minimum of \(minimum) is required")
+ }
+ }
+
+ return reasons
+ }
+
+ /// The few places an installer may not write unless an administrator names them explicitly.
+ ///
+ /// Deliberately short, and not a general hardening list. Once an entry has said "this vendor,
+ /// this identifier, notarized", a package that installs a launch daemon or a command in
+ /// `/usr/local/bin` is not misbehaving — that is what Docker, a VPN client and most developer
+ /// tooling *are*. Refusing those by default would mean the feature did not work for a large part
+ /// of real software, and the answer administrators would reach for is `AllowUnrestrictedPayload`
+ /// on every entry, which is worse than never having checked.
+ ///
+ /// What is left is a different kind of thing: not "software doing something privileged" but
+ /// "software taking over the thing that decides what software may do". None of these is a
+ /// destination an application installer has any reason to write to, and each one turns a single
+ /// install into lasting control of the mechanism meant to be governing it.
+ ///
+ /// An administrator who wants the stricter posture still has it, by naming prefixes on the entry
+ /// — opting in to tight, rather than opting out of unusable.
+ public static let protectedPrefixes = [
+ // Deciding what may be installed, and who may hold administrator rights.
+ "/Library/Managed Preferences",
+ "/Library/Preferences",
+ "/var/db/com.github.macadmins.SupportCompanion",
+ "/private/var/db/com.github.macadmins.SupportCompanion",
+
+ // Becoming something privileged, or standing in front of one.
+ "/Library/PrivilegedHelperTools",
+ "/Library/Security",
+ "/Library/ScriptingAdditions",
+
+ // Local accounts: writing here is how an administrator account appears out of nothing.
+ "/var/db/dslocal",
+ "/private/var/db/dslocal",
+
+ // Root, directly.
+ "/etc/sudoers",
+ "/etc/sudoers.d",
+ "/etc/pam.d",
+ "/etc/ssh",
+ "/private/etc/sudoers",
+ "/private/etc/sudoers.d",
+ "/private/etc/pam.d",
+ "/private/etc/ssh",
+ "/var/root",
+ "/private/var/root",
+ ]
+
+ /// Whether `path` is the same as `prefix` or sits inside it.
+ ///
+ /// Compared at path-component boundaries, so `/Applications/Firefox.app` is not treated as being
+ /// inside `/Applications/Fire`.
+ static func isPath(_ path: String, under prefix: String) -> Bool {
+ let path = (path as NSString).standardizingPath
+ let prefix = (prefix as NSString).standardizingPath
+
+ if path == prefix { return true }
+
+ return path.hasPrefix(prefix.hasSuffix("/") ? prefix : prefix + "/")
+ }
+
+ /// Compare two dotted versions numerically, so 10.2 is newer than 10.10 is not mistaken for true.
+ static func isVersion(_ version: String, olderThan minimum: String) -> Bool {
+ let left = components(of: version)
+ let right = components(of: minimum)
+
+ for index in 0.. [Int] {
+ version
+ .split(whereSeparator: { !$0.isNumber })
+ .map { Int($0) ?? 0 }
+ }
+}
+
+// MARK: - Convenience
+
+// Kept file-private: this file is compiled into both the app and the helper, and a name this general
+// on String would be waiting to collide with one of them.
+extension String {
+ fileprivate var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
+}
diff --git a/Helper/Launchd.plist b/Helper/Launchd.plist
index a5440dc..219fa8c 100644
--- a/Helper/Launchd.plist
+++ b/Helper/Launchd.plist
@@ -15,6 +15,8 @@
RunAtLoad
+ KeepAlive
+
Disabled
diff --git a/Helper/PackageInspector.swift b/Helper/PackageInspector.swift
new file mode 100644
index 0000000..bd06125
--- /dev/null
+++ b/Helper/PackageInspector.swift
@@ -0,0 +1,503 @@
+//
+// PackageInspector.swift
+// com.github.macadmins.SupportCompanion.helper
+//
+
+import Foundation
+
+/// Reads what an installer package claims to be, from a copy only root can write.
+///
+/// Every function here takes a path inside the staging directory, never the path the user chose. The
+/// order matters: the bytes are copied first, and only the copy is ever inspected or installed, so
+/// there is no window in which the file that was judged and the file that gets installed can differ.
+enum PackageInspector {
+
+ // MARK: Signing
+
+ /// What `spctl` and `pkgutil` between them say about a package's signature.
+ struct Signature {
+ var trusted = false
+ var notarized = false
+ var teamID: String?
+ var authority: String?
+ var leafCertificateSHA256: String?
+ }
+
+ /// Ask Gatekeeper to assess the package as an installation.
+ ///
+ /// This is `SecAssessmentCreate` with `kSecAssessmentOperationTypeInstall` behind a command line,
+ /// which reports the authority and the source in one place rather than a nested dictionary.
+ ///
+ /// It answers to the machine's Gatekeeper configuration, so on a Mac where assessments have been
+ /// turned off it approves everything. That is why the certificate chain is checked separately below
+ /// and why `notarized` is reported as false when assessments are disabled: a policy that says
+ /// "notarized only" must not quietly become "anything" because somebody ran `spctl --global-disable`.
+ private static func gatekeeperAssessment(of path: String) async -> (accepted: Bool, source: String?, origin: String?) {
+ guard await assessmentsAreEnabled() else {
+ Logger.shared.logError("Gatekeeper assessments are disabled on this Mac; treating \(path) as unassessed")
+ return (false, nil, nil)
+ }
+
+ let result = try? await ProcessRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"),
+ arguments: ["--assess", "--type", "install", "-vv", path]
+ )
+
+ guard let result else { return (false, nil, nil) }
+
+ // spctl writes its detail to standard error, including when it accepts.
+ let output = (String(data: result.output, encoding: .utf8) ?? "")
+ + (String(data: result.error, encoding: .utf8) ?? "")
+
+ return (
+ result.status == 0,
+ value(ofKey: "source", in: output),
+ value(ofKey: "origin", in: output)
+ )
+ }
+
+ private static func assessmentsAreEnabled() async -> Bool {
+ let result = try? await ProcessRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/sbin/spctl"),
+ arguments: ["--status"]
+ )
+
+ guard let result else { return false }
+
+ let output = (String(data: result.output, encoding: .utf8) ?? "")
+ + (String(data: result.error, encoding: .utf8) ?? "")
+
+ return output.contains("assessments enabled")
+ }
+
+ private static func value(ofKey key: String, in output: String) -> String? {
+ output
+ .split(separator: "\n")
+ .first { $0.hasPrefix("\(key)=") }
+ .map { String($0.dropFirst(key.count + 1)).trimmingCharacters(in: .whitespaces) }
+ }
+
+ /// The `pkgutil --check-signature` statuses that count as a real, trusted signature.
+ ///
+ /// Listing what is accepted rather than what is rejected means a status this was never taught
+ /// about fails closed. The first is what an ordinary Developer ID installer package reports and is
+ /// the common case; note that it is specifically *for distribution*, since the same sentence ends
+ /// "(Development)" for a development certificate, which is not something to install from.
+ private static let acceptedStatuses = [
+ "signed by a developer certificate issued by Apple for distribution",
+ "signed by a certificate trusted by",
+ "signed Apple Software",
+ ]
+
+ /// Check the package's own signature independently of Gatekeeper, and read the signing team.
+ ///
+ /// The Team ID comes from the leaf certificate's common name, which `pkgutil` prints as
+ /// `Developer ID Installer: Example Ltd. (ABCDE12345)`. Matching on the parenthesised team rather
+ /// than the name is deliberate: the name is chosen by the developer and is not unique.
+ private static func certificateChain(of path: String) async -> (trusted: Bool, teamID: String?, authority: String?, leaf: String?) {
+ let result = try? await ProcessRunner.run(
+ executableURL: URL(fileURLWithPath: "/usr/sbin/pkgutil"),
+ arguments: ["--check-signature", path]
+ )
+
+ guard let result, result.status == 0 else { return (false, nil, nil, nil) }
+
+ let output = String(data: result.output, encoding: .utf8) ?? ""
+
+ let status = output
+ .split(separator: "\n")
+ .first { $0.contains("Status:") }
+ .map { String($0).trimmingCharacters(in: .whitespaces) } ?? ""
+
+ let trusted = acceptedStatuses.contains { status.contains($0) }
+
+ guard trusted else {
+ Logger.shared.logError("Package at \(path) is not trusted: \(status)")
+ return (false, nil, nil, nil)
+ }
+
+ // The leaf is the first numbered entry in the chain.
+ let leaf = output
+ .split(separator: "\n")
+ .first { $0.trimmingCharacters(in: .whitespaces).hasPrefix("1. ") }
+ .map { String($0).trimmingCharacters(in: .whitespaces).dropFirst(3) }
+ .map(String.init)
+
+ let fingerprint = leafFingerprint(in: output)
+
+ guard let leaf else { return (true, nil, nil, fingerprint) }
+
+ let teamID = leaf.range(of: #"\(([A-Z0-9]{10})\)$"#, options: .regularExpression)
+ .map { String(leaf[$0].dropFirst().dropLast()) }
+
+ return (true, teamID, leaf, fingerprint)
+ }
+
+ /// The SHA-256 of the leaf certificate, from the fingerprint `pkgutil` prints beneath it.
+ ///
+ /// Printed as space-separated hex across however many lines it takes, under the first numbered
+ /// entry in the chain — which is the leaf. Collection stops at the first line that is not hex,
+ /// which is the rule separating one certificate from the next, so a fingerprint from further down
+ /// the chain can never be mistaken for the leaf's.
+ static func leafFingerprint(in output: String) -> String? {
+ var insideLeaf = false
+ var collecting = false
+ var digest = ""
+
+ for line in output.split(separator: "\n", omittingEmptySubsequences: false) {
+ let trimmed = line.trimmingCharacters(in: .whitespaces)
+
+ if trimmed.hasPrefix("1. ") {
+ insideLeaf = true
+ continue
+ }
+
+ guard insideLeaf else { continue }
+
+ // Any other numbered entry ends the leaf's section.
+ if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil { break }
+
+ if trimmed.hasPrefix("SHA256 Fingerprint:") {
+ collecting = true
+ continue
+ }
+
+ guard collecting else { continue }
+
+ let hex = trimmed.replacingOccurrences(of: " ", with: "")
+
+ guard !hex.isEmpty, hex.allSatisfy(\.isHexDigit) else { break }
+
+ digest += hex
+ }
+
+ let normalized = digest.lowercased()
+
+ return normalized.count == 64 ? normalized : nil
+ }
+
+ /// Whether a Gatekeeper `source=` names a notarized authority.
+ static func isNotarized(source: String?) -> Bool {
+ guard let source = source?.trimmingCharacters(in: .whitespaces) else { return false }
+ return source.hasPrefix("Notarized")
+ }
+
+ static func signature(of path: String) async -> Signature {
+ let chain = await certificateChain(of: path)
+ let gatekeeper = await gatekeeperAssessment(of: path)
+
+ return Signature(
+ trusted: chain.trusted,
+ // Accepted *and* attributed to a notarized authority. Gatekeeper accepts other things too,
+ // such as anything already on the system's own allow list. Matched at the start rather
+ // than anywhere in the string: the source for a signed-but-unnotarized package is
+ // "Unnotarized Developer ID", which contains the word either way.
+ notarized: gatekeeper.accepted && isNotarized(source: gatekeeper.source),
+ teamID: chain.teamID,
+ authority: chain.authority ?? gatekeeper.origin,
+ leafCertificateSHA256: chain.leaf
+ )
+ }
+
+ // MARK: Contents
+
+ struct Contents {
+ var identifiers: [String] = []
+ var version: String?
+ var title: String?
+ var hasScripts = false
+ var scriptSummary: String?
+ var hasRemoteReferences = false
+ }
+
+ /// Expand the package and read what it will install.
+ ///
+ /// `--expand` rather than `--expand-full`: the payloads stay as archives, which is all that is
+ /// needed to know whether a component carries scripts, and avoids unpacking attacker-chosen
+ /// archives as root just to look at them.
+ static func contents(of path: String, expandingInto directory: String) async throws -> Contents {
+ _ = try await ExecutionService.run("/usr/sbin/pkgutil", ["--expand", path, directory])
+
+ var contents = Contents()
+
+ let distribution = (directory as NSString).appendingPathComponent("Distribution")
+
+ if FileManager.default.fileExists(atPath: distribution) {
+ readDistribution(at: distribution, into: &contents)
+ }
+
+ for component in componentDirectories(in: directory) {
+ readPackageInfo(at: (component as NSString).appendingPathComponent("PackageInfo"), into: &contents)
+
+ // A `Scripts` archive beside the payload is the component's pre/postinstall scripts, and
+ // they run as root. Its presence is enough; there is no need to unpack it.
+ if FileManager.default.fileExists(atPath: (component as NSString).appendingPathComponent("Scripts")) {
+ contents.hasScripts = true
+ contents.scriptSummary = contents.scriptSummary ?? "install scripts that run as root"
+ }
+ }
+
+ contents.identifiers = Array(Set(contents.identifiers)).sorted()
+
+ return contents
+ }
+
+ private static func readDistribution(at path: String, into contents: inout Contents) {
+ guard let document = try? XMLDocument(contentsOf: URL(fileURLWithPath: path), options: []) else {
+ Logger.shared.logError("Unable to read the Distribution file at \(path)")
+ return
+ }
+
+ contents.title = (try? document.nodes(forXPath: "//title"))?.first?.stringValue?
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+
+ for node in (try? document.nodes(forXPath: "//pkg-ref")) ?? [] {
+ guard let element = node as? XMLElement else { continue }
+
+ if let id = element.attribute(forName: "id")?.stringValue, !id.isEmpty {
+ contents.identifiers.append(id)
+ }
+
+ if contents.version == nil, let version = element.attribute(forName: "version")?.stringValue, !version.isEmpty {
+ contents.version = version
+ }
+
+ // A reference whose body is a URL is fetched while installing. Those bytes arrive after
+ // everything here has finished looking, so a package that has one can never be judged.
+ let body = element.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+
+ if body.hasPrefix("http://") || body.hasPrefix("https://") {
+ contents.hasRemoteReferences = true
+ }
+ }
+
+ // JavaScript in the distribution runs inside the installer process, which is root here.
+ if let scripts = try? document.nodes(forXPath: "//installer-gui-script/script"), !scripts.isEmpty {
+ contents.hasScripts = true
+ contents.scriptSummary = contents.scriptSummary ?? "installer JavaScript"
+ }
+ }
+
+ private static func readPackageInfo(at path: String, into contents: inout Contents) {
+ guard
+ FileManager.default.fileExists(atPath: path),
+ let document = try? XMLDocument(contentsOf: URL(fileURLWithPath: path), options: []),
+ let root = document.rootElement()
+ else { return }
+
+ if let id = root.attribute(forName: "identifier")?.stringValue, !id.isEmpty {
+ contents.identifiers.append(id)
+ }
+
+ if contents.version == nil, let version = root.attribute(forName: "version")?.stringValue, !version.isEmpty {
+ contents.version = version
+ }
+
+ if let scripts = try? document.nodes(forXPath: "//scripts/*"), !scripts.isEmpty {
+ contents.hasScripts = true
+ contents.scriptSummary = contents.scriptSummary ?? "install scripts that run as root"
+ }
+ }
+
+ // MARK: Payload
+
+ /// Bundle extensions that are a destination in their own right, so a path stops there.
+ private static let bundleExtensions: Set = [
+ "app", "framework", "bundle", "plugin", "kext", "prefPane", "qlgenerator", "xpc", "appex", "systemextension",
+ ]
+
+ /// The most roots a package may have before it is refused outright.
+ ///
+ /// Not a display limit — every one of these is checked. A package with more distinct destinations
+ /// than this is pathological, and refusing is the only answer that does not involve deciding
+ /// which of them not to look at.
+ static let maximumRoots = 512
+
+ /// Where a package's payload will land, shortened to the shallowest path containing each part.
+ ///
+ /// Read from each component's bill of materials, files and symlinks only. Directories are skipped
+ /// deliberately: every package "installs" `/Applications` in the sense of ensuring it exists, and
+ /// collapsing on that would report one useless root for everything.
+ ///
+ /// Each path is truncated at the first bundle it enters, or to three components, so an application
+ /// reports `/Applications/Firefox.app` rather than ten thousand files. Truncation only ever moves
+ /// a path towards the root, so a check against these roots also covers everything beneath them.
+ static func payloadRoots(inExpanded directory: String) async throws -> [String] {
+ var roots: Set = []
+
+ for component in componentDirectories(in: directory) {
+ let bom = (component as NSString).appendingPathComponent("Bom")
+ guard FileManager.default.fileExists(atPath: bom) else { continue }
+
+ let installLocation = (try? XMLDocument(
+ contentsOf: URL(fileURLWithPath: (component as NSString).appendingPathComponent("PackageInfo")),
+ options: []
+ ))?
+ .rootElement()?
+ .attribute(forName: "install-location")?
+ .stringValue ?? "/"
+
+ // A component whose bill of materials cannot be read contributes no destinations, and so
+ // could never be blocked by any of them. For something deciding what root writes, "could
+ // not look" has to mean "no", not "nothing to see".
+ guard let output = try? await ExecutionService.run("/usr/bin/lsbom", ["-f", "-l", "-s", bom]) else {
+ Logger.shared.logError("Unable to read the bill of materials at \(bom)")
+ throw SupportCompanionErrors.helperConnection(
+ "Part of this package could not be read, so where it installs cannot be established"
+ )
+ }
+
+ for line in output.split(separator: "\n") {
+ let relative = line.hasPrefix(".") ? String(line.dropFirst()) : String(line)
+ guard !relative.isEmpty, relative != "/" else { continue }
+
+ let full = (installLocation as NSString).appendingPathComponent(relative)
+
+ // AppleDouble sidecars carry the extended attributes of the file beside them and are
+ // merged into it on arrival; they are metadata, not a destination of their own.
+ // Reporting them would put `/Applications/._Foo.app` next to `/Applications/Foo.app`
+ // and make any sensible prefix fail.
+ guard !((full as NSString).lastPathComponent.hasPrefix("._")) else { continue }
+
+ roots.insert(root(of: full))
+ }
+
+ // A symlink in the payload is a destination of its own: `installer` writes through it, so
+ // a link at /Applications/Foo.app/Contents/x pointing at /Library/LaunchDaemons puts the
+ // payload there while every nominal path still reads as being inside the app. Resolving
+ // the target and treating it as another root means the ordinary prefix and deny-list
+ // checks cover it, rather than needing a rule of their own.
+ guard let links = try? await ExecutionService.run("/usr/bin/lsbom", ["-l", "-p", "fl", bom]) else {
+ Logger.shared.logError("Unable to read the symbolic links in \(bom)")
+ throw SupportCompanionErrors.helperConnection(
+ "Part of this package could not be read, so where it installs cannot be established"
+ )
+ }
+
+ for line in links.split(separator: "\n") {
+ let parts = line.split(separator: "\t", maxSplits: 1)
+ guard parts.count == 2 else { continue }
+
+ let linkPath = String(parts[0])
+ let target = String(parts[1]).trimmingCharacters(in: .whitespaces)
+ guard !target.isEmpty else { continue }
+
+ let relative = linkPath.hasPrefix(".") ? String(linkPath.dropFirst()) : linkPath
+ guard !((relative as NSString).lastPathComponent.hasPrefix("._")) else { continue }
+
+ let full = (installLocation as NSString).appendingPathComponent(relative)
+
+ let resolved = target.hasPrefix("/")
+ ? target
+ : ((full as NSString).deletingLastPathComponent as NSString).appendingPathComponent(target)
+
+ roots.insert(root(of: resolved))
+ }
+ }
+
+ let all = minimal(roots).sorted()
+
+ // Never truncated here. This used to return at most a couple of dozen, which was harmless
+ // while these were something to show the user — but they are now what the deny list is
+ // checked against, so dropping one silently drops a refusal. A package with two dozen
+ // destinations sorting before "/L" would have pushed /Library/LaunchDaemons off the end and
+ // been allowed. Whatever is displayed is shortened at the point of display instead.
+ guard all.count <= maximumRoots else {
+ throw SupportCompanionErrors.helperConnection(
+ "This package writes to \(all.count) different places, which is more than can be checked"
+ )
+ }
+
+ return all
+ }
+
+ /// The directories holding a `PackageInfo`: one per component, or the package itself when it has
+ /// no components of its own.
+ private static func componentDirectories(in directory: String) -> [String] {
+ var directories: [String] = []
+
+ if FileManager.default.fileExists(atPath: (directory as NSString).appendingPathComponent("PackageInfo")) {
+ directories.append(directory)
+ }
+
+ for entry in (try? FileManager.default.contentsOfDirectory(atPath: directory)) ?? [] {
+ let path = (directory as NSString).appendingPathComponent(entry)
+
+ if FileManager.default.fileExists(atPath: (path as NSString).appendingPathComponent("PackageInfo")) {
+ directories.append(path)
+ }
+ }
+
+ return directories
+ }
+
+ private static func root(of path: String) -> String {
+ let components = (path as NSString).standardizingPath
+ .split(separator: "/", omittingEmptySubsequences: true)
+ .map(String.init)
+
+ var kept: [String] = []
+
+ for component in components {
+ kept.append(component)
+
+ if bundleExtensions.contains((component as NSString).pathExtension) { break }
+ if kept.count == 3 { break }
+ }
+
+ return "/" + kept.joined(separator: "/")
+ }
+
+ /// Drop any path that already sits inside another one in the set.
+ private static func minimal(_ paths: Set) -> [String] {
+ var kept: [String] = []
+
+ for path in paths.sorted() {
+ if let last = kept.last, InstallerPolicy.isPath(path, under: last) { continue }
+ kept.append(path)
+ }
+
+ return kept
+ }
+
+ // MARK: Facts
+
+ /// Everything the policy needs to know about a staged package.
+ static func facts(forStagedPackageAt path: String, fileName: String, expandingInto directory: String) async throws -> InstallerFacts {
+ let digest = try StagedFile.sha256(ofFileAt: path)
+ let signature = await signature(of: path)
+
+ var facts = InstallerFacts(
+ kind: .package,
+ fileName: fileName,
+ sha256: digest,
+ teamID: signature.teamID,
+ authority: signature.authority,
+ leafCertificateSHA256: signature.leafCertificateSHA256,
+ signatureTrusted: signature.trusted,
+ notarized: signature.notarized
+ )
+
+ // A package that will not expand is not one we are going to install, but the digest and the
+ // signature are still worth reporting: they are what an administrator needs to allow it.
+ do {
+ let contents = try await contents(of: path, expandingInto: directory)
+
+ facts.identifiers = contents.identifiers
+ facts.version = contents.version
+ facts.displayName = contents.title
+ facts.hasScripts = contents.hasScripts
+ facts.scriptSummary = contents.scriptSummary
+ facts.hasRemoteReferences = contents.hasRemoteReferences
+ } catch {
+ Logger.shared.logError("Unable to expand the package at \(path): \(error.localizedDescription)")
+ throw SupportCompanionErrors.helperConnection("This does not look like an installer package")
+ }
+
+ // Outside the catch above, so that "where does this install" failing is reported as itself
+ // rather than as "this is not a package".
+ facts.payloadRoots = try await payloadRoots(inExpanded: directory)
+
+ return facts
+ }
+}
diff --git a/README.md b/README.md
index 9720c30..10ac9fb 100644
--- a/README.md
+++ b/README.md
@@ -1,44 +1,23 @@
# [#SupportCompanion](https://macadmins.slack.com/archives/C075C6ZFAJH)
-
+
-## Description
-
-Support Companion is a macOS helper application, designed to empower end-users by providing them with
-quick and easy access to crucial information and actions. This application is built to streamline a variety of
-tasks, eliminating the need for extensive searching and complex navigation. Support Companion is equipped with a range
-of features that enhance user productivity.
-
-For getting started with Support Companion, please refer to the [Wiki](https://github.com/macadmins/supportcompanion/wiki).
-
-## Overview
-
-### Tray Menu
-
-### Home
-
-
-### Identity
-
-
-### Apps
-
-
-### Self Service
-
+## Description
-### KB
-
+Support Companion is a macOS app that IT deploys to give people one place to see the
+state of their Mac and fix common problems themselves, instead of opening a ticket.
-### Desktop Info
-
+It surfaces device, storage and battery details, patching progress and pending updates,
+and, in Fleet mode, the compliance checks failing on the Mac along with the steps to
+resolve them. People can install approved software from a self-service catalog, run
+actions you define, and request time-limited admin rights with a logged reason. The
+whole app carries your organisation's name, logo and accent colour.
-### Notifications
-
+It reads from whatever you already run: **Munki**, **Jamf Pro**, **Microsoft Intune**,
+**Fleet**, or plain system profiler.
-### Company Portal
-
+For setup, see the [Wiki](https://github.com/macadmins/supportcompanion/wiki).
## Credits
[woodys-findings](https://www.woodys-findings.com/posts/cocoa-implement-privileged-helper) for privileged helper code
diff --git a/SupportCompanion.xcodeproj/project.pbxproj b/SupportCompanion.xcodeproj/project.pbxproj
index 3357053..6341cdc 100644
--- a/SupportCompanion.xcodeproj/project.pbxproj
+++ b/SupportCompanion.xcodeproj/project.pbxproj
@@ -8,38 +8,38 @@
/* Begin PBXBuildFile section */
F60FAB742CE8D40A00ECAC53 /* AlertToast in Frameworks */ = {isa = PBXBuildFile; productRef = F60FAB732CE8D40A00ECAC53 /* AlertToast */; };
- F60FAB872CEA108A00ECAC53 /* SidebarConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = F60FAB862CEA108A00ECAC53 /* SidebarConfig.swift */; };
F627CBCB2E0AF1DF00164680 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = F627CBCA2E0AF1DF00164680 /* AppIcon.icon */; };
- F64927372CF9EA8D00C34D90 /* com.github.macadmins.SupportCompanion.helper in CopyFiles */ = {isa = PBXBuildFile; fileRef = F6F8AE7D2CE345A8009B0A1F /* com.github.macadmins.SupportCompanion.helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
- F6575A992D117108007DBC83 /* ExecutionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE9C2CE34E54009B0A1F /* ExecutionService.swift */; };
- F6575A9A2D117114007DBC83 /* HelperRemoteProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE9E2CE34E72009B0A1F /* HelperRemoteProvider.swift */; };
F6575A9D2D117141007DBC83 /* SupportCompanionErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE952CE34B81009B0A1F /* SupportCompanionErrors.swift */; };
F6575AA72D11827D007DBC83 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = F6575AA62D11827D007DBC83 /* MarkdownUI */; };
F6890EEB2CFF326D00244985 /* SupportCompanionCLI in CopyFiles */ = {isa = PBXBuildFile; fileRef = F6890EE22CFF308F00244985 /* SupportCompanionCLI */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
- F6890EEC2CFF330D00244985 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E4D1D42CE4CE1E003EB8C0 /* Preferences.swift */; };
F6890EED2CFF331400244985 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A4E0F62CEC9DFC002B4D74 /* Constants.swift */; };
F6A4E0F72CEC9DFC002B4D74 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A4E0F62CEC9DFC002B4D74 /* Constants.swift */; };
F6A4E0F82CEC9DFC002B4D74 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6A4E0F62CEC9DFC002B4D74 /* Constants.swift */; };
F6A4E0FA2CEC9FFB002B4D74 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = F6A4E0F92CEC9FFB002B4D74 /* Localizable.xcstrings */; };
- F6E4D1D52CE4CE1E003EB8C0 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E4D1D42CE4CE1E003EB8C0 /* Preferences.swift */; };
- F6E4D26F2CE65D12003EB8C0 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6E4D26E2CE65D12003EB8C0 /* AppDelegate.swift */; };
- F6F3BEBD2CE1E7BA0036ADB9 /* SupportCompanion.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F3BEBC2CE1E7BA0036ADB9 /* SupportCompanion.swift */; };
- F6F3BEBF2CE1E7BA0036ADB9 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F3BEBE2CE1E7BA0036ADB9 /* ContentView.swift */; };
+ F6CB698E2F69570000CCD8DF /* com.github.macadmins.SupportCompanion.helper in CopyFiles */ = {isa = PBXBuildFile; fileRef = F6F8AE7D2CE345A8009B0A1F /* com.github.macadmins.SupportCompanion.helper */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
F6F3BEC12CE1E7BB0036ADB9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F6F3BEC02CE1E7BB0036ADB9 /* Assets.xcassets */; };
F6F8AE962CE34B81009B0A1F /* SupportCompanionErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE952CE34B81009B0A1F /* SupportCompanionErrors.swift */; };
F6F8AE972CE34B81009B0A1F /* SupportCompanionErrors.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE952CE34B81009B0A1F /* SupportCompanionErrors.swift */; };
- F6F8AE9D2CE34E54009B0A1F /* ExecutionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE9C2CE34E54009B0A1F /* ExecutionService.swift */; };
- F6F8AE9F2CE34E72009B0A1F /* HelperRemoteProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F6F8AE9E2CE34E72009B0A1F /* HelperRemoteProvider.swift */; };
/* End PBXBuildFile section */
+/* Begin PBXContainerItemProxy section */
+ 0BAD9E672EAE42EFB6A28573 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = F6F3BEB12CE1E7BA0036ADB9 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = F6F3BEB82CE1E7BA0036ADB9;
+ remoteInfo = SupportCompanion;
+ };
+/* End PBXContainerItemProxy section */
+
/* Begin PBXCopyFilesBuildPhase section */
F63587542CE22B6000AC5F98 /* CopyFiles */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 12;
- dstPath = Contents/Library/LaunchServices;
+ dstPath = Contents/Library/LaunchDaemons;
dstSubfolderSpec = 1;
files = (
- F64927372CF9EA8D00C34D90 /* com.github.macadmins.SupportCompanion.helper in CopyFiles */,
+ F6CB698E2F69570000CCD8DF /* com.github.macadmins.SupportCompanion.helper in CopyFiles */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -74,7 +74,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
- F60FAB862CEA108A00ECAC53 /* SidebarConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarConfig.swift; sourceTree = ""; };
+ 920DA95B75064C6DB9C1CB06 /* SupportCompanionTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SupportCompanionTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
F627CBCA2E0AF1DF00164680 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; };
F649273D2CF9FFD600C34D90 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = CHANGELOG.md; sourceTree = ""; };
F64928192CFA02AD00C34D90 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; };
@@ -82,19 +82,13 @@
F69455982CF6076100B887F8 /* build.zsh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = build.zsh; sourceTree = ""; };
F6A4E0F62CEC9DFC002B4D74 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; };
F6A4E0F92CEC9FFB002B4D74 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
- F6E4D1D42CE4CE1E003EB8C0 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; };
- F6E4D26E2CE65D12003EB8C0 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
F6F3BEB92CE1E7BA0036ADB9 /* SupportCompanion.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SupportCompanion.app; sourceTree = BUILT_PRODUCTS_DIR; };
- F6F3BEBC2CE1E7BA0036ADB9 /* SupportCompanion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportCompanion.swift; sourceTree = ""; };
- F6F3BEBE2CE1E7BA0036ADB9 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; };
F6F3BEC02CE1E7BB0036ADB9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
F6F3BEC32CE1E7BB0036ADB9 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; };
F6F3BEC52CE1E7BB0036ADB9 /* SupportCompanion.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = SupportCompanion.entitlements; sourceTree = ""; };
F6F8AE772CE342AF009B0A1F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
F6F8AE7D2CE345A8009B0A1F /* com.github.macadmins.SupportCompanion.helper */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = com.github.macadmins.SupportCompanion.helper; sourceTree = BUILT_PRODUCTS_DIR; };
F6F8AE952CE34B81009B0A1F /* SupportCompanionErrors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupportCompanionErrors.swift; sourceTree = ""; };
- F6F8AE9C2CE34E54009B0A1F /* ExecutionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExecutionService.swift; sourceTree = ""; };
- F6F8AE9E2CE34E72009B0A1F /* HelperRemoteProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperRemoteProvider.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -134,8 +128,9 @@
F6575A9C2D117133007DBC83 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
- HelperConstans.swift,
+ HelperConstants.swift,
HelperProtocol.swift,
+ InstallerPolicy.swift,
"OSStatus+Extensions.swift",
RemoteApplicationProtocol.swift,
);
@@ -156,11 +151,15 @@
membershipExceptions = (
BatteryHelpers.swift,
DeviceInfoHelpers.swift,
+ HardwareInfoHelpers.swift,
IOKit.swift,
Logger.swift,
MDMHelpers.swift,
+ NetworkInfoHelpers.swift,
+ ProcessRunner.swift,
SSOInfoHelpers.swift,
StorageHelpers.swift,
+ TrustedPreferences.swift,
UserHelpers.swift,
);
target = F6890EE12CFF308F00244985 /* SupportCompanionCLI */;
@@ -187,6 +186,7 @@
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Logger.swift,
+ ProcessRunner.swift,
);
target = F6F8AE7C2CE345A8009B0A1F /* com.github.macadmins.SupportCompanion.helper */;
};
@@ -200,32 +200,62 @@
DeviceInfoManager.swift,
ElevationManager.swift,
EvergreenInfoManager.swift,
+ FleetDeviceManager.swift,
+ FleetSoftwareManager.swift,
IdentityViewModel.swift,
+ JamfInfoManager.swift,
JsonCardManager.swift,
MDMInfoManager.swift,
+ PendingFleetUpdatesManager.swift,
PendingIntuneUpdatesManager.swift,
+ PendingJamfUpdatesManager.swift,
PendingMunkiUpdatesManager.swift,
+ PendingUpdatesManager.swift,
ReasonInputManager.swift,
SSOInfoManager.swift,
StorageInfoManager.swift,
SystemUpdatesManager.swift,
UserInfoManager.swift,
+ UserInstallManager.swift,
);
target = F6F3BEB82CE1E7BA0036ADB9 /* SupportCompanion */;
};
F6A4E0E82CEBD7F8002B4D74 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
- HelperConstans.swift,
+ HelperConstants.swift,
HelperProtocol.swift,
+ InstallerPolicy.swift,
"OSStatus+Extensions.swift",
RemoteApplicationProtocol.swift,
);
target = F6F3BEB82CE1E7BA0036ADB9 /* SupportCompanion */;
};
+ F6CCE02D305BD27400007CDE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ BrandingPreferences.swift,
+ DefaultsStore.swift,
+ DesktopInfoPreferences.swift,
+ ElevationPreferences.swift,
+ NotificationPreferences.swift,
+ Preferences.swift,
+ );
+ target = F6890EE12CFF308F00244985 /* SupportCompanionCLI */;
+ };
+ F6CCE02E305BD2AB00007CDE /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ ExecutionService.swift,
+ Fleet/FleetDeviceIdentity.swift,
+ HelperRemoteProvider.swift,
+ );
+ target = F6890EE12CFF308F00244985 /* SupportCompanionCLI */;
+ };
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
+ 9045A39F2C3843BEA6527820 /* SupportCompanionTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = SupportCompanionTests; sourceTree = ""; };
F60FAB792CE94CC000ECAC53 /* Assets */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F68910C62D009DB500244985 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Assets; sourceTree = ""; };
F635873C2CE2094400AC5F98 /* Views */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Views; sourceTree = ""; };
F64927222CF9AA0400C34D90 /* LaunchAgent */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F64927362CF9D3FE00C34D90 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = LaunchAgent; sourceTree = ""; };
@@ -234,7 +264,9 @@
F69455822CF5A6D300B887F8 /* Protocols */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Protocols; sourceTree = ""; };
F69455882CF603A600B887F8 /* Scripts */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Scripts; sourceTree = ""; };
F694558E2CF6050C00B887F8 /* pkgbuild */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F69455C82CF7521A00B887F8 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = pkgbuild; sourceTree = ""; };
- F6A4E0F32CEC752C002B4D74 /* Services */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Services; sourceTree = ""; };
+ F6A4E0F32CEC752C002B4D74 /* Services */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F6CCE02E305BD2AB00007CDE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Services; sourceTree = ""; };
+ F6CCE02C305BD26200007CDE /* Preferences */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F6CCE02D305BD27400007CDE /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Preferences; sourceTree = ""; };
+ F6CCE02F305BD2B500007CDE /* App */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = App; sourceTree = ""; };
F6E4D2032CE5DF67003EB8C0 /* Extensions */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Extensions; sourceTree = ""; };
F6E4D2662CE64E62003EB8C0 /* Controllers */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = Controllers; sourceTree = ""; };
F6E4D26B2CE6576B003EB8C0 /* Helpers */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (F6A4E0E32CEBD770002B4D74 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, F6890EF12CFF333C00244985 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = Helpers; sourceTree = ""; };
@@ -246,6 +278,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
+ 0A7ACA4229D24CB3B1C7345E /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
F6890EDF2CFF308F00244985 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -288,6 +327,7 @@
F6F3BEBB2CE1E7BA0036ADB9 /* SupportCompanion */,
F6F8AE7E2CE345A8009B0A1F /* Helper */,
F6890EE32CFF308F00244985 /* SupportCompanionCLI */,
+ 9045A39F2C3843BEA6527820 /* SupportCompanionTests */,
F60ED7952CEB278400960264 /* Frameworks */,
F6F3BEBA2CE1E7BA0036ADB9 /* Products */,
F69455982CF6076100B887F8 /* build.zsh */,
@@ -301,6 +341,7 @@
F6F3BEB92CE1E7BA0036ADB9 /* SupportCompanion.app */,
F6F8AE7D2CE345A8009B0A1F /* com.github.macadmins.SupportCompanion.helper */,
F6890EE22CFF308F00244985 /* SupportCompanionCLI */,
+ 920DA95B75064C6DB9C1CB06 /* SupportCompanionTests.xctest */,
);
name = Products;
sourceTree = "";
@@ -308,6 +349,8 @@
F6F3BEBB2CE1E7BA0036ADB9 /* SupportCompanion */ = {
isa = PBXGroup;
children = (
+ F6CCE02F305BD2B500007CDE /* App */,
+ F6CCE02C305BD26200007CDE /* Preferences */,
F694558E2CF6050C00B887F8 /* pkgbuild */,
F69455882CF603A600B887F8 /* Scripts */,
F69455822CF5A6D300B887F8 /* Protocols */,
@@ -321,18 +364,11 @@
F6E72E642CE3DEC300D78336 /* Models */,
F635873C2CE2094400AC5F98 /* Views */,
F6EB39E72CE1EE2800517FCD /* ViewModels */,
- F6F3BEBC2CE1E7BA0036ADB9 /* SupportCompanion.swift */,
- F6F3BEBE2CE1E7BA0036ADB9 /* ContentView.swift */,
F6F3BEC02CE1E7BB0036ADB9 /* Assets.xcassets */,
F6F3BEC52CE1E7BB0036ADB9 /* SupportCompanion.entitlements */,
F6F3BEC22CE1E7BB0036ADB9 /* Preview Content */,
F6F8AE772CE342AF009B0A1F /* Info.plist */,
F6F8AE952CE34B81009B0A1F /* SupportCompanionErrors.swift */,
- F6F8AE9C2CE34E54009B0A1F /* ExecutionService.swift */,
- F6F8AE9E2CE34E72009B0A1F /* HelperRemoteProvider.swift */,
- F6E4D1D42CE4CE1E003EB8C0 /* Preferences.swift */,
- F6E4D26E2CE65D12003EB8C0 /* AppDelegate.swift */,
- F60FAB862CEA108A00ECAC53 /* SidebarConfig.swift */,
F6A4E0F62CEC9DFC002B4D74 /* Constants.swift */,
F6A4E0F92CEC9FFB002B4D74 /* Localizable.xcstrings */,
F64927222CF9AA0400C34D90 /* LaunchAgent */,
@@ -351,6 +387,29 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
+ 913299348C16468C81BC005F /* SupportCompanionTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 5FCC4A90F16E4AEC8E707808 /* Build configuration list for PBXNativeTarget "SupportCompanionTests" */;
+ buildPhases = (
+ 5BFF812034644F11B2837F83 /* Sources */,
+ 0A7ACA4229D24CB3B1C7345E /* Frameworks */,
+ 74904BAB62CD4D598F94FCE4 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ F139030B1A214775A553AF81 /* PBXTargetDependency */,
+ );
+ fileSystemSynchronizedGroups = (
+ 9045A39F2C3843BEA6527820 /* SupportCompanionTests */,
+ );
+ name = SupportCompanionTests;
+ packageProductDependencies = (
+ );
+ productName = SupportCompanionTests;
+ productReference = 920DA95B75064C6DB9C1CB06 /* SupportCompanionTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
F6890EE12CFF308F00244985 /* SupportCompanionCLI */ = {
isa = PBXNativeTarget;
buildConfigurationList = F6890EE62CFF308F00244985 /* Build configuration list for PBXNativeTarget "SupportCompanionCLI" */;
@@ -395,6 +454,8 @@
F69455882CF603A600B887F8 /* Scripts */,
F694558E2CF6050C00B887F8 /* pkgbuild */,
F6A4E0F32CEC752C002B4D74 /* Services */,
+ F6CCE02C305BD26200007CDE /* Preferences */,
+ F6CCE02F305BD2B500007CDE /* App */,
F6E4D2032CE5DF67003EB8C0 /* Extensions */,
F6E4D2662CE64E62003EB8C0 /* Controllers */,
F6E4D26B2CE6576B003EB8C0 /* Helpers */,
@@ -441,6 +502,10 @@
LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1600;
TargetAttributes = {
+ 913299348C16468C81BC005F = {
+ CreatedOnToolsVersion = 27.0;
+ TestTargetID = F6F3BEB82CE1E7BA0036ADB9;
+ };
F6890EE12CFF308F00244985 = {
CreatedOnToolsVersion = 16.1;
};
@@ -477,11 +542,19 @@
F6F3BEB82CE1E7BA0036ADB9 /* SupportCompanion */,
F6F8AE7C2CE345A8009B0A1F /* com.github.macadmins.SupportCompanion.helper */,
F6890EE12CFF308F00244985 /* SupportCompanionCLI */,
+ 913299348C16468C81BC005F /* SupportCompanionTests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
+ 74904BAB62CD4D598F94FCE4 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
F6B8C9222CE35E2400E98B36 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -502,14 +575,18 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+ 5BFF812034644F11B2837F83 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
F6890EDE2CFF308F00244985 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- F6890EEC2CFF330D00244985 /* Preferences.swift in Sources */,
F6575A9D2D117141007DBC83 /* SupportCompanionErrors.swift in Sources */,
- F6575A992D117108007DBC83 /* ExecutionService.swift in Sources */,
- F6575A9A2D117114007DBC83 /* HelperRemoteProvider.swift in Sources */,
F6890EED2CFF331400244985 /* Constants.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -518,14 +595,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- F60FAB872CEA108A00ECAC53 /* SidebarConfig.swift in Sources */,
- F6F8AE9F2CE34E72009B0A1F /* HelperRemoteProvider.swift in Sources */,
- F6F3BEBF2CE1E7BA0036ADB9 /* ContentView.swift in Sources */,
- F6F3BEBD2CE1E7BA0036ADB9 /* SupportCompanion.swift in Sources */,
- F6E4D26F2CE65D12003EB8C0 /* AppDelegate.swift in Sources */,
F6F8AE962CE34B81009B0A1F /* SupportCompanionErrors.swift in Sources */,
- F6F8AE9D2CE34E54009B0A1F /* ExecutionService.swift in Sources */,
- F6E4D1D52CE4CE1E003EB8C0 /* Preferences.swift in Sources */,
F6A4E0F82CEC9DFC002B4D74 /* Constants.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -541,18 +611,69 @@
};
/* End PBXSourcesBuildPhase section */
+/* Begin PBXTargetDependency section */
+ F139030B1A214775A553AF81 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = F6F3BEB82CE1E7BA0036ADB9 /* SupportCompanion */;
+ targetProxy = 0BAD9E672EAE42EFB6A28573 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
/* Begin XCBuildConfiguration section */
+ 7B204651D84A4491AE10337E /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_IDENTITY = "-";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanionTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SupportCompanion.app/Contents/MacOS/SupportCompanion";
+ };
+ name = Debug;
+ };
+ 8E15AAC54963408DA825D7FF /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_IDENTITY = "-";
+ CODE_SIGN_STYLE = Manual;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_TEAM = "";
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "";
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanionTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/SupportCompanion.app/Contents/MacOS/SupportCompanion";
+ };
+ name = Release;
+ };
F6890EE72CFF308F00244985 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
- "DEVELOPMENT_TEAM[sdk=macosx*]" = H92SB6Z7S4;
+ "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ;
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.CLI;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+ SDKROOT = macosx;
SWIFT_VERSION = 5.0;
};
name = Debug;
@@ -563,11 +684,12 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
- "DEVELOPMENT_TEAM[sdk=macosx*]" = H92SB6Z7S4;
+ "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ;
MACOSX_DEPLOYMENT_TARGET = 14.0;
PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.CLI;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+ SDKROOT = macosx;
SWIFT_VERSION = 5.0;
};
name = Release;
@@ -702,7 +824,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO;
- CODE_SIGN_ENTITLEMENTS = SupportCompanion/SupportCompanion.entitlements;
+ CODE_SIGN_ENTITLEMENTS = SupportCompanion/SupportCompanion-Debug.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
@@ -710,7 +832,8 @@
CURRENT_PROJECT_VERSION = 1.0;
DEVELOPMENT_ASSET_PATHS = "\"SupportCompanion/Preview Content\"";
DEVELOPMENT_TEAM = "";
- "DEVELOPMENT_TEAM[sdk=macosx*]" = H92SB6Z7S4;
+ "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ;
+ ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "$(SRCROOT)/SupportCompanion/Info.plist";
@@ -723,14 +846,14 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
- TEAM_ID = H92SB6Z7S4;
+ TEAM_ID = 42EJ7ZYMPQ;
};
name = Debug;
};
@@ -748,7 +871,8 @@
CURRENT_PROJECT_VERSION = 1.0;
DEVELOPMENT_ASSET_PATHS = "\"SupportCompanion/Preview Content\"";
DEVELOPMENT_TEAM = "";
- "DEVELOPMENT_TEAM[sdk=macosx*]" = H92SB6Z7S4;
+ "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ;
+ ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "$(SRCROOT)/SupportCompanion/Info.plist";
@@ -761,7 +885,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 1.0;
+ MARKETING_VERSION = 3.0.0;
PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -780,7 +904,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
- "DEVELOPMENT_TEAM[sdk=macosx*]" = H92SB6Z7S4;
+ "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = "$(SRCROOT)/Helper/Info.plist";
MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -792,13 +916,15 @@
"-sectcreate",
__TEXT,
__launchd_plist,
- "\"$(SRCROOT)/Helper/Launchd.plist",
+ "\"$(SRCROOT)/Helper/Launchd.plist\"",
+ "-lbsm",
);
PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.helper;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+ SDKROOT = macosx;
SWIFT_VERSION = 5.0;
- TEAM_ID = H92SB6Z7S4;
+ TEAM_ID = 42EJ7ZYMPQ;
};
name = Debug;
};
@@ -810,7 +936,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";
CODE_SIGN_STYLE = Manual;
DEVELOPMENT_TEAM = "";
- "DEVELOPMENT_TEAM[sdk=macosx*]" = H92SB6Z7S4;
+ "DEVELOPMENT_TEAM[sdk=macosx*]" = 42EJ7ZYMPQ;
ENABLE_HARDENED_RUNTIME = YES;
INFOPLIST_FILE = "$(SRCROOT)/Helper/Info.plist";
MACOSX_DEPLOYMENT_TARGET = 14.0;
@@ -822,11 +948,13 @@
"-sectcreate",
__TEXT,
__launchd_plist,
- "\"$(SRCROOT)/Helper/Launchd.plist",
+ "\"$(SRCROOT)/Helper/Launchd.plist\"",
+ "-lbsm",
);
PRODUCT_BUNDLE_IDENTIFIER = com.github.macadmins.SupportCompanion.helper;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
+ SDKROOT = macosx;
SWIFT_VERSION = 5.0;
TEAM_ID = T4SK8ZXCXG;
};
@@ -835,6 +963,15 @@
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
+ 5FCC4A90F16E4AEC8E707808 /* Build configuration list for PBXNativeTarget "SupportCompanionTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 7B204651D84A4491AE10337E /* Debug */,
+ 8E15AAC54963408DA825D7FF /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
F6890EE62CFF308F00244985 /* Build configuration list for PBXNativeTarget "SupportCompanionCLI" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/SupportCompanion.xcodeproj/xcshareddata/xcschemes/SupportCompanion.xcscheme b/SupportCompanion.xcodeproj/xcshareddata/xcschemes/SupportCompanion.xcscheme
index 2301939..83e0a36 100644
--- a/SupportCompanion.xcodeproj/xcshareddata/xcschemes/SupportCompanion.xcscheme
+++ b/SupportCompanion.xcodeproj/xcshareddata/xcschemes/SupportCompanion.xcscheme
@@ -49,20 +49,9 @@
parallelizable = "YES">
-
-
-
-
diff --git a/SupportCompanion/App/AppDelegate.swift b/SupportCompanion/App/AppDelegate.swift
new file mode 100644
index 0000000..74c9739
--- /dev/null
+++ b/SupportCompanion/App/AppDelegate.swift
@@ -0,0 +1,528 @@
+//
+// AppDelegate.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2024-11-14.
+//
+
+import AppKit
+import Combine
+import Foundation
+import SwiftUI
+import UserNotifications
+
+@MainActor
+class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
+ var popover: NSPopover!
+ var statusItem: NSStatusItem?
+ var windowController: NSWindowController?
+ var transparentWindowController: TransparentWindowController?
+ let appStateManager = AppStateManager.shared
+ let elevationManager = ElevationManager.shared
+ var mainWindow: NSWindow?
+ static var urlLaunch = false
+ static var shouldExit = false
+ private var notificationDelegate: NotificationDelegate?
+ private var trayIconObservation: ObservationToken?
+ private var elevationCountdownObservation: ObservationToken?
+ private var dockBadgeObservation: ObservationToken?
+ private var popoverEventMonitors: [Any] = []
+ private var popoverKeyWindowObserver: NSObjectProtocol?
+ private var trayManager: TrayMenuManager { TrayMenuManager.shared }
+
+ @AppStorage("isDarkMode") private var isDarkMode: Bool = false
+
+ var hasUpdatesAvailable: Bool {
+ appStateManager.attentionCount > 0
+ }
+
+ private func executeAction(_ action: Action) {
+ Task {
+ do {
+ _ = try await ExecutionService.runAction(action)
+ } catch {
+ Logger.shared.logError("Failed to execute action: \(error)")
+ }
+ }
+ }
+
+ func application(_ application: NSApplication, open urls: [URL]) {
+ // Installers the user double-clicked, when this app is registered to open them. Taken before
+ // the custom scheme is looked at: these are file URLs and have no host to switch on.
+ let installers = urls.filter {
+ $0.isFileURL && InstallerFileTypes.fileExtensions.contains($0.pathExtension.lowercased())
+ }
+
+ if let first = installers.first {
+ AppDelegate.shouldExit = false
+
+ // One at a time. The rest go where they would have gone anyway rather than queueing up
+ // behind a window the user has not answered yet.
+ for other in installers.dropFirst() {
+ NSWorkspace.shared.open(other)
+ }
+
+ UserInstallManager.shared.open(first)
+ return
+ }
+
+ guard let url = urls.first else { return }
+
+ if url.host == "run" {
+ Logger.shared.logDebug("Received run command request")
+ if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems
+ {
+ if let actionName = queryItems.first(where: { $0.name == "action" })?.value {
+ // Get the action details
+ if let action = appStateManager.preferences.actions.first(where: {
+ $0.name == actionName
+ }) {
+ Logger.shared.logDebug("Found action: \(action.name)")
+ if action.isPrivileged ?? false
+ && appStateManager.preferences.requirePrivilegedActionAuthentication
+ {
+ Logger.shared.logDebug("Action requires authentication")
+ authenticateWithTouchIDOrPassword(
+ completion: { success in
+ if success {
+ self.executeAction(action)
+ } else {
+ Logger.shared.logError(
+ "Authentication failed. Action: \(action.name) was not executed."
+ )
+ }
+ }, reason: "authenticate to execute this privileged action.")
+ } else {
+ Logger.shared.logDebug("Executing action: \(action.name)")
+ self.executeAction(action)
+ }
+ } else {
+ Logger.shared.logError("Action not found: \(actionName)")
+ }
+ }
+ }
+ return
+ }
+
+ switch url.host?.lowercased() {
+ case nil:
+ AppDelegate.shouldExit = true
+ if let statusItem = statusItem {
+ Logger.shared.logDebug("Removing status item")
+ NSStatusBar.system.removeStatusItem(statusItem)
+ self.statusItem = nil
+ }
+ default:
+ AppDelegate.shouldExit = false
+ }
+ AppDelegate.urlLaunch = true
+ showWindow()
+ NotificationCenter.default.post(name: .handleIncomingURL, object: url)
+ }
+
+ /// The Services menu item, for an installer the user right-clicked in Finder.
+ @objc func installOpenedInstaller(
+ _ pasteboard: NSPasteboard,
+ userData: String,
+ error: AutoreleasingUnsafeMutablePointer
+ ) {
+ guard
+ let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL],
+ let first = urls.first(where: {
+ InstallerFileTypes.fileExtensions.contains($0.pathExtension.lowercased())
+ })
+ else {
+ error.pointee = "No installer package or disk image was selected" as NSString
+ return
+ }
+
+ UserInstallManager.shared.open(first)
+ }
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ // Unit tests are hosted in the app; don't start the menu bar item, timers, or notifications for them
+ guard ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] == nil else {
+ return
+ }
+
+ if !AppDelegate.shouldExit && appStateManager.preferences.trayMenuShowIcon {
+ setupTrayMenu()
+ }
+
+ popover = NSPopover()
+ // Not .transient: that closes the popover whenever the app resigns active, which
+ // happens when the Jamf install percentage refresh force-quits Self Service+ (macOS 27).
+ // Outside clicks and Escape are handled by installPopoverEventMonitors() instead.
+ popover.behavior = .applicationDefined
+ popover.contentSize = NSSize(width: 500, height: 500)
+ popover.contentViewController = NSHostingController(
+ rootView: TrayMenuView(
+ viewModel: CardGridViewModel(appState: AppStateManager.shared)
+ )
+ .environment(AppStateManager.shared)
+ )
+ popover.delegate = self
+
+ configureAppUpdateNotificationCommand()
+
+ appStateManager.showWindowCallback = { [weak self] in
+ self?.showWindow()
+ }
+
+ if appStateManager.preferences.desktopInfo.showDesktopInfo {
+ // Initialize transparent window
+ transparentWindowController = TransparentWindowController(appState: appStateManager)
+ transparentWindowController?.showWindow(nil)
+
+ // Make sure the transparent window is set up correctly
+ if let window = NSApplication.shared.windows.first {
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ }
+ }
+
+ // The right-click "Install with Support Companion" item.
+ NSApp.servicesProvider = self
+ NSUpdateDynamicServices()
+
+ // Whether Finder actually offers it. Applied on every launch rather than once, so turning the
+ // preference off takes the item away again.
+ InstallerServiceMenu.apply(showing: appStateManager.preferences.showInstallerServiceMenuItem)
+
+ // What this process read, so that a disagreement with the helper — which reads the same
+ // setting by a different route — is visible in one place.
+ Logger.shared.logInfo(
+ "User installs: EnableUserInstalls=\(appStateManager.preferences.enableUserInstalls)"
+ )
+
+ requestNotificationPermissions()
+ notificationDelegate = NotificationDelegate()
+ UNUserNotificationCenter.current().delegate = notificationDelegate
+ appStateManager.startBackgroundTasks()
+ appStateManager.refreshAll()
+ checkAndHandleDemotionOnLaunch()
+ if !appStateManager.preferences.hiddenCards.contains(Constants.Cards.jamfInfo)
+ && appStateManager.preferences.mode == Constants.Modes.jamf
+ {
+ Task {
+ let id: String
+ do {
+ id = try await getJamfId()
+ } catch {
+ Logger.shared.logError("getJamfId failed: \(error.localizedDescription)")
+ id = "Unknown"
+ }
+ await MainActor.run {
+ AppStateManager.shared.jamfId = id
+ AppStateManager.shared.jamfInfoManager.refresh()
+ }
+ }
+ }
+ }
+
+ private func checkAndHandleDemotionOnLaunch() {
+ // Demotion is the helper's job and it happens whether or not this app is running, including
+ // while it was quit. All there is to do at launch is pick up the countdown already in progress.
+ Task { @MainActor in
+ let remainingTime = await elevationManager.remainingElevationTime()
+
+ guard remainingTime > 0 else { return }
+
+ elevationManager.startDemotionTimer(duration: remainingTime) { remainingTime in
+ Task { @MainActor in
+ AppStateManager.shared.timeToDemote = remainingTime
+ AppStateManager.shared.isDemotionActive = remainingTime > 0
+ }
+ }
+ }
+ }
+
+ private func setupTrayMenu() {
+ let trayManager = TrayMenuManager.shared
+ if statusItem == nil {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+
+ setupTrayMenuIconBinding()
+
+ if let button = trayManager.getStatusItem().button {
+ button.action = #selector(togglePopover)
+ button.target = self
+ }
+ }
+ }
+
+ func setupTrayMenuIconBinding() {
+ // Updates or failing compliance checks
+ let hasUpdates = { [unowned self] () -> Bool in
+ self.appStateManager.attentionCount > 0
+ }
+ TrayMenuManager.shared.updateTrayIcon(hasUpdates: hasUpdates())
+ dockBadgeObservation = observeChanges(of: { [unowned self] in
+ self.appStateManager.attentionCount
+ }) { count in
+ BadgeManager.shared.incrementBadgeCount(count: count)
+ }
+ trayIconObservation = observeChanges(of: hasUpdates) { hasUpdates in
+ TrayMenuManager.shared.updateTrayIcon(hasUpdates: hasUpdates)
+ }
+
+ // Count down next to the icon while administrator rights are held, so the time left is
+ // visible without opening anything. The demotion timer already publishes once a second.
+ let remaining = { [unowned self] () -> TimeInterval in
+ self.appStateManager.isDemotionActive ? self.appStateManager.timeToDemote : 0
+ }
+ TrayMenuManager.shared.updateElevationCountdown(remaining: remaining())
+ elevationCountdownObservation = observeChanges(of: remaining) { remaining in
+ TrayMenuManager.shared.updateElevationCountdown(remaining: remaining)
+ }
+ }
+
+ @MainActor
+ class TrayMenuManager {
+ static let shared = TrayMenuManager()
+ let appStateManager = AppStateManager.shared
+ let fileManager = FileManager.default
+ private var statusItem: NSStatusItem
+
+ private init() {
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ updateTrayIcon(hasUpdates: false) // Default state
+ }
+
+ func updateTrayIcon(hasUpdates: Bool) {
+ let iconName = "MenuIcon"
+ let base64Logo = appStateManager.preferences.trayMenuBrandingIcon
+ var showLogo = false
+ var baseIcon: NSImage?
+
+ showLogo = loadLogo(base64Logo: base64Logo)
+ if showLogo {
+ guard let data = Data(base64Encoded: base64Logo) else {
+ Logger.shared.logError("Error: Failed to decode base64 logo for tray icon")
+ return
+ }
+ baseIcon = NSImage(data: data)
+ } else {
+ baseIcon = NSImage(named: iconName)
+ }
+
+ guard let baseIcon = baseIcon else {
+ Logger.shared.logError("Error: Failed to load tray menu icon")
+ return
+ }
+
+ baseIcon.size = NSSize(width: 18, height: 18)
+ baseIcon.isTemplate = true // Ensure base icon respects system appearance
+
+ if let button = statusItem.button {
+ // Clear any existing layers
+ button.layer?.sublayers?.forEach { $0.removeFromSuperlayer() }
+
+ // Set the base icon as the button's image
+ button.image = baseIcon
+ button.image?.isTemplate = true
+
+ if hasUpdates {
+ Logger.shared.logDebug("Updates available, adding badge to tray icon")
+
+ // Add badge dynamically as a layer
+ let badgeLayer = CALayer()
+ badgeLayer.backgroundColor = NSColor.red.cgColor
+ badgeLayer.frame = CGRect(
+ x: button.bounds.width - 15, // Align to the lower-right corner
+ y: 13, // Small offset from the bottom
+ width: 8,
+ height: 8
+ )
+ badgeLayer.cornerRadius = 4 // Make it circular
+
+ // Ensure button has a layer to add sublayers
+ if button.layer == nil {
+ button.wantsLayer = true
+ button.layer = CALayer()
+ }
+
+ button.layer?.addSublayer(badgeLayer)
+ }
+ }
+ }
+
+ /// Show the time left on an active elevation beside the tray icon.
+ ///
+ /// Set as the button's title rather than drawn into the image: the status item is
+ /// variable-length, so the text lays out beside the icon on its own, and `updateTrayIcon`
+ /// rebuilds the image and its layers without disturbing it.
+ func updateElevationCountdown(remaining: TimeInterval) {
+ guard let button = statusItem.button else { return }
+
+ guard remaining > 0 else {
+ button.title = ""
+ button.toolTip = nil
+ return
+ }
+
+ button.title = " \(remaining.formattedTime())"
+ button.toolTip = Constants.General.demote
+ }
+
+ func getStatusItem() -> NSStatusItem {
+ return statusItem
+ }
+ }
+
+ @objc private func togglePopover() {
+ guard let button = trayManager.getStatusItem().button else {
+ Logger.shared.logError("Error: TrayMenuManager's statusItem.button is nil")
+ return
+ }
+
+ if popover.isShown {
+ popover.performClose(nil)
+ } else {
+ // Dynamically set the popover content
+ popover.contentViewController = NSHostingController(
+ rootView: TrayMenuView(
+ viewModel: CardGridViewModel(appState: AppStateManager.shared)
+ )
+ .environment(AppStateManager.shared)
+ )
+
+ // Anchor the popover to the status item's button
+ popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
+
+ // Ensure the popover window is brought to the front
+ if let popoverWindow = popover.contentViewController?.view.window {
+ popoverWindow.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ installPopoverEventMonitors()
+ }
+ }
+
+ private func installPopoverEventMonitors() {
+ removePopoverEventMonitors()
+
+ // Another of this app's windows taking focus, e.g. the main window opened from the popover
+ popoverKeyWindowObserver = NotificationCenter.default.addObserver(
+ forName: NSWindow.didBecomeKeyNotification, object: nil, queue: .main
+ ) { [weak self] notification in
+ MainActor.assumeIsolated {
+ guard let self, let window = notification.object as? NSWindow else { return }
+ if window !== self.popover.contentViewController?.view.window {
+ self.closePopover()
+ }
+ }
+ }
+
+ // Clicks in other apps
+ if let globalMonitor = NSEvent.addGlobalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown],
+ handler: { [weak self] _ in
+ Task { @MainActor in self?.closePopover() }
+ })
+ {
+ popoverEventMonitors.append(globalMonitor)
+ }
+
+ // Clicks in this app's other windows, and Escape
+ if let localMonitor = NSEvent.addLocalMonitorForEvents(
+ matching: [.leftMouseDown, .rightMouseDown, .otherMouseDown, .keyDown],
+ handler: { [weak self] event in
+ guard let self else { return event }
+ if event.type == .keyDown {
+ if event.keyCode == 53 { // Escape
+ self.closePopover()
+ return nil
+ }
+ return event
+ }
+ let popoverWindow = self.popover.contentViewController?.view.window
+ let statusItemWindow = self.trayManager.getStatusItem().button?.window
+ // Clicks on the status item are left to togglePopover
+ if event.window !== popoverWindow && event.window !== statusItemWindow {
+ self.closePopover()
+ }
+ return event
+ })
+ {
+ popoverEventMonitors.append(localMonitor)
+ }
+ }
+
+ private func removePopoverEventMonitors() {
+ popoverEventMonitors.forEach { NSEvent.removeMonitor($0) }
+ popoverEventMonitors.removeAll()
+ if let popoverKeyWindowObserver {
+ NotificationCenter.default.removeObserver(popoverKeyWindowObserver)
+ self.popoverKeyWindowObserver = nil
+ }
+ }
+
+ private func closePopover() {
+ if popover.isShown {
+ popover.performClose(nil)
+ }
+ }
+
+ func popoverDidClose(_ notification: Notification) {
+ Logger.shared.logDebug("Popover closed, cleaning up...")
+ removePopoverEventMonitors()
+
+ // Cleanup logic: release the popover or its content
+ popover.contentViewController = nil
+ }
+
+ @objc func showWindow() {
+ if windowController == nil {
+ NSApp.setActivationPolicy(.regular)
+ let contentView = ContentView()
+ .environment(AppStateManager.shared)
+ .environment(AppStateManager.shared.preferences)
+ .frame(minWidth: 1100, minHeight: 650)
+
+ let hostingController = NSHostingController(rootView: contentView)
+
+ let window = NSWindow(contentViewController: hostingController)
+ window.setContentSize(NSSize(width: 1500, height: 1020))
+ window.styleMask = [.titled, .closable, .resizable]
+ window.minSize = NSSize(width: 1100, height: 650)
+ window.title = ""
+ window.isReleasedWhenClosed = false
+ window.backgroundColor = .clear
+ window.titlebarAppearsTransparent = true
+ window.styleMask.insert(.fullSizeContentView)
+ window.center()
+ window.level = .normal
+
+ // Assign a delegate to handle window lifecycle
+ window.delegate = self
+
+ windowController = NSWindowController(window: window)
+ }
+
+ windowController?.showWindow(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ @objc private func runAction(_ sender: NSMenuItem) {
+ guard let action = sender.representedObject as? Action else { return }
+ Task {
+ do {
+ _ = try await ExecutionService.runAction(action)
+ } catch {
+ Logger.shared.logError("Tray menu action '\(action.name)' failed: \(error)")
+ }
+ }
+ }
+
+ @objc private func quitApp() {
+ NSApplication.shared.terminate(nil)
+ }
+
+ private func configureAppUpdateNotificationCommand() {
+ guard let manager = appStateManager.activeUpdatesManager else { return }
+ appStateManager.preferences.notifications.appUpdateNotificationCommand =
+ "open \(manager.managementApp(forUpdates: true).path)"
+ }
+}
diff --git a/SupportCompanion/App/ContentView.swift b/SupportCompanion/App/ContentView.swift
new file mode 100644
index 0000000..5adbcf9
--- /dev/null
+++ b/SupportCompanion/App/ContentView.swift
@@ -0,0 +1,337 @@
+//
+// ContentView.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2024-11-11.
+//
+
+import Combine
+import SwiftUI
+
+struct ContentView: View {
+ @State private var selectedItem: SidebarItem?
+ @Namespace private var animationNamespace
+ @Environment(Preferences.self) var preferences
+ @Environment(AppStateManager.self) var appState
+ @State private var webViewStateManager = WebViewStateManager()
+ @State private var cardGridViewModel = CardGridViewModel(appState: AppStateManager.shared)
+ @State private var brandLogo: Image? = nil
+ @State private var showLogo: Bool = false
+ @State private var isShowingPopup = false
+ @State private var modalButtonHovered: Bool = false
+ @Environment(\.colorScheme) var colorScheme
+
+ var body: some View {
+ let sidebarItems: [SidebarItem] = generateSidebarItems(
+ preferences: appState.preferences, stateManager: webViewStateManager,
+ cardGridViewModel: cardGridViewModel, pendingUpdatesCount: appState.pendingUpdatesCount,
+ failingChecksCount: appState.fleetFailingChecksCount)
+ let accentColor = Color(accentNSColor)
+
+ NavigationSplitView {
+ VStack(spacing: 10) {
+ Spacer() // Push content to the center dynamically
+
+ // Logo Section
+ if showLogo, let logo = brandLogo {
+ logo
+ .resizable()
+ .interpolation(.high)
+ .antialiased(true)
+ .scaledToFit()
+ .frame(maxWidth: 230)
+ .drawingGroup()
+ .fixedSize(horizontal: false, vertical: true)
+ .padding(.top, 20) // Minimal padding
+ .padding(.horizontal, 20)
+ }
+
+ // Title Section
+ if !appState.preferences.branding.brandName.isEmpty {
+ Text(appState.preferences.branding.brandName)
+ .font(.title)
+ .multilineTextAlignment(.center)
+ .padding(.top, 20) // Bring the title closer to the logo
+ }
+
+ Spacer() // Push content to the center dynamically
+
+ // Sidebar List (custom to avoid List clipping)
+ SidebarListView(
+ items: sidebarItems,
+ selectedItem: selectedItem,
+ onSelect: { item in
+ withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
+ selectedItem = item
+ }
+ },
+ accentColor: accentColor,
+ namespace: animationNamespace,
+ onAppear: {
+ loadLogoForCurrentColorScheme()
+ if selectedItem == nil {
+ selectedItem = sidebarItems.first
+ }
+ },
+ onColorSchemeChange: {
+ loadLogoForCurrentColorScheme()
+ },
+ onBrandLogoChange: {
+ loadLogoForCurrentColorScheme()
+ },
+ onBrandLogoLightChange: {
+ loadLogoForCurrentColorScheme()
+ },
+ onIncomingURL: { url in
+ handleIncomingURL(url, items: sidebarItems)
+ }
+ )
+ .background(Color.clear)
+ }
+ .navigationSplitViewColumnWidth(
+ min: 280, ideal: 280, max: 320
+ )
+ .frame(maxHeight: .infinity, alignment: .top)
+ .background(Color.clear)
+ } detail: {
+ Group {
+ if let selectedItem = selectedItem {
+ selectedItem.destination
+ .id(selectedItem.id)
+ } else {
+ Text("Select an option") // Placeholder if nothing is selected
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.gray.opacity(0.1))
+ }
+ }
+ .toolbar {
+ ToolbarItem(placement: .automatic) {
+ ToolbarSupportButton(isShowingPopup: $isShowingPopup)
+ }
+
+ if #available(macOS 26.0, *) {
+ ToolbarSpacer(.fixed)
+ }
+
+ ToolbarItem(placement: .automatic) {
+ ToolbarDarkModeToggleView()
+ }
+ }
+ }
+ .sheet(isPresented: $isShowingPopup) {
+ // The popup content
+ PopupModal(isShowing: $isShowingPopup)
+ }
+ // Set the background color based on the color scheme with opacity
+ .background(colorScheme == .dark ? Color.black.opacity(0.4) : Color.white.opacity(0.4))
+ .background(.ultraThinMaterial)
+ // One sign-in sheet for the window: both Fleet pages start the same sign-in, and only one of
+ // them is ever in the detail pane. Attached out here rather than to the detail pane, because a
+ // sheet centres on the view it is attached to -- on the detail pane it sits off to the right.
+ .sheet(isPresented: Bindable(appState.fleetSSOController).isPresented) {
+ FleetSSOSignInSheet(controller: appState.fleetSSOController)
+ }
+ }
+
+ private func handleIncomingURL(_ url: URL, items: [SidebarItem]) {
+ guard url.scheme == "supportcompanion" else { return }
+
+ switch url.host?.lowercased() {
+ case "home":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.home })
+ case "identity":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.identity })
+ case "apps":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.apps })
+ case "selfservice":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.selfService })
+ case "compliance":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.compliance })
+ case "companyportal":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.companyPortal })
+ case "knowledgebase":
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.knowledgeBase })
+ case "markdown":
+ selectedItem = items.first(where: { $0.id == appState.preferences.markdownMenuLabel })
+ case "fleetsignin":
+ // Reached from the tray cards and the sign-in notification, where there may be no window
+ // yet. Opening the window is the AppDelegate's job; this lands the user somewhere that
+ // makes sense once signed in, and puts the sheet up.
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.apps })
+ ?? items.first(where: { $0.id == Constants.Navigation.compliance })
+ appState.fleetSSOController.present()
+ default:
+ selectedItem = items.first(where: { $0.id == Constants.Navigation.home })
+ }
+ }
+
+ private func loadLogoForCurrentColorScheme() {
+ let preferredLight = appState.preferences.branding.brandLogoLight
+ let darkLogo = appState.preferences.branding.brandLogo
+ let lightLogo = preferredLight.isEmpty ? darkLogo : preferredLight
+ let base64Logo = (colorScheme == .dark) ? darkLogo : lightLogo
+
+ showLogo = loadLogo(base64Logo: base64Logo)
+ if showLogo {
+ brandLogo = base64ToImage(base64Logo)
+ }
+ }
+
+ struct ToolbarSupportButton: View {
+ @Environment(AppStateManager.self) var appState
+ @Binding var isShowingPopup: Bool
+
+ var body: some View {
+ if !appState.preferences.supportEmail.isEmpty
+ && !appState.preferences.supportPhone.isEmpty
+ {
+ Button {
+ isShowingPopup = true
+ } label: {
+ Label("Support Information", systemImage: "phone")
+ }
+ }
+ }
+ }
+
+ struct ToolbarDarkModeToggleView: View {
+ var body: some View {
+ DarkLightModeToggle()
+ .padding(.trailing, 5)
+ .padding(.leading, 5)
+ }
+ }
+
+ struct SidebarRowView: View {
+ let item: SidebarItem
+ let isSelected: Bool
+ let accentColor: Color
+ let namespace: Namespace.ID
+ let onSelect: () -> Void
+ @State private var isHovered: Bool = false
+
+ var body: some View {
+ ZStack {
+ // Background layers: selected highlight or hover highlight
+ if isSelected {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(accentColor)
+ .matchedGeometryEffect(id: "sidebar-highlight", in: namespace)
+ .frame(height: 38)
+ } else if isHovered {
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.primary.opacity(0.08))
+ .frame(height: 38)
+ }
+
+ // Row content
+ HStack(spacing: 8) {
+ Image(systemName: item.systemImage)
+ .resizable()
+ .scaledToFit()
+ .frame(width: 20, height: 20)
+
+ Text(item.label)
+ .frame(maxWidth: .infinity, alignment: .leading)
+
+ if item.badge > 0 {
+ Text("\(item.badge)")
+ .font(.caption2.bold())
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(isSelected ? Color.white.opacity(0.25) : Color.red)
+ .foregroundColor(.white)
+ .clipShape(Capsule())
+ }
+ }
+ .padding(.horizontal, 15)
+ .padding(.vertical, 10)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .foregroundColor(isSelected ? .white : .primary)
+ .onTapGesture(perform: onSelect)
+ .onHover { hovering in
+ isHovered = hovering
+ }
+ }
+ }
+}
+
+private struct SidebarListView: View {
+ let items: [SidebarItem]
+ let selectedItem: SidebarItem?
+ let onSelect: (SidebarItem) -> Void
+ let accentColor: Color
+ let namespace: Namespace.ID
+
+ let onAppear: () -> Void
+ let onColorSchemeChange: () -> Void
+ let onBrandLogoChange: () -> Void
+ let onBrandLogoLightChange: () -> Void
+ let onIncomingURL: (URL) -> Void
+
+ var body: some View {
+ ScrollView {
+ LazyVStack(spacing: 0) {
+ ForEach(items) { item in
+ ContentView.SidebarRowView(
+ item: item,
+ isSelected: selectedItem == item,
+ accentColor: accentColor,
+ namespace: namespace,
+ onSelect: { onSelect(item) }
+ )
+ .padding(.horizontal, 8)
+ .padding(.vertical, 5)
+ .zIndex(selectedItem == item ? 1 : 0)
+ }
+ }
+ .padding(.vertical, 8)
+ }
+ .onAppear(perform: onAppear)
+ .onChange(of: colorScheme) { _, _ in onColorSchemeChange() }
+ .onReceive(NotificationCenter.default.publisher(for: .handleIncomingURL)) { notification in
+ if let url = notification.object as? URL {
+ onIncomingURL(url)
+ }
+ }
+ .onChange(of: AppStateManager.shared.preferences.branding.brandLogo) { _, _ in
+ onBrandLogoChange()
+ }
+ .onChange(of: AppStateManager.shared.preferences.branding.brandLogoLight) { _, _ in
+ onBrandLogoLightChange()
+ }
+ }
+
+ @Environment(\.colorScheme) private var colorScheme
+}
+
+extension ContentView {
+ fileprivate var accentNSColor: NSColor {
+ NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? NSColor.controlAccentColor
+ }
+}
+
+struct SidebarItemStyle: ViewModifier {
+ func body(content: Content) -> some View {
+ content
+ .font(.system(size: 16))
+ .bold()
+ .padding()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .toolbar(removing: .sidebarToggle)
+ }
+}
+
+struct ContentView_Previews: PreviewProvider {
+ static var previews: some View {
+ ContentView()
+ .environment(AppStateManager.shared.preferences)
+ .environment(DeviceInfoManager.shared)
+ .environment(StorageInfoManager.shared)
+ .environment(MdmInfoManager.shared)
+ .environment(BatteryInfoManager.shared)
+ .frame(width: 1500, height: 900)
+ }
+}
diff --git a/SupportCompanion/SidebarConfig.swift b/SupportCompanion/App/SidebarConfig.swift
similarity index 72%
rename from SupportCompanion/SidebarConfig.swift
rename to SupportCompanion/App/SidebarConfig.swift
index 20a34ad..32d118a 100644
--- a/SupportCompanion/SidebarConfig.swift
+++ b/SupportCompanion/App/SidebarConfig.swift
@@ -8,21 +8,31 @@
import Foundation
import SwiftUI
-func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateManager) -> [SidebarItem] {
+@MainActor
+func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateManager, cardGridViewModel: CardGridViewModel, pendingUpdatesCount: Int = 0, failingChecksCount: Int = 0) -> [SidebarItem] {
var items: [SidebarItem] = [
SidebarItem(
label: Constants.Navigation.home,
systemImage: "house.fill",
destination: AnyView(
- CardGrid(
- viewModel: CardGridViewModel(
- appState: AppStateManager.shared
- )
- )
+ CardGrid(viewModel: cardGridViewModel)
)
)
]
+ if preferences.mode == Constants.Modes.fleet && !preferences.hiddenCards.contains(Constants.Cards.fleetPolicies) {
+ items.append(
+ SidebarItem(
+ label: Constants.Navigation.compliance,
+ systemImage: "checkmark.shield.fill",
+ destination: AnyView(
+ FleetComplianceView()
+ ),
+ badge: failingChecksCount
+ )
+ )
+ }
+
if preferences.menuShowIdentity {
items.append(
SidebarItem(
@@ -40,13 +50,14 @@ func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateMa
SidebarItem(
label: Constants.Navigation.apps,
systemImage: "app.fill",
- destination: AnyView(
- Applications()
- )
+ destination: preferences.mode == Constants.Modes.fleet
+ ? AnyView(FleetAppsView())
+ : AnyView(Applications()),
+ badge: pendingUpdatesCount
)
)
}
-
+
if !preferences.actions.isEmpty && preferences.menuShowSelfService {
items.append(
SidebarItem(
@@ -72,7 +83,7 @@ func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateMa
)
}
}
-
+
if !preferences.customCardPath.isEmpty && !preferences.customCardsMenuLabel.isEmpty {
if FileManager.default.fileExists(atPath: preferences.customCardPath) {
items.append(
@@ -84,13 +95,14 @@ func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateMa
)
}
}
-
+
// Add "Company Portal" with persistent WebViewState
if preferences.menuShowCompanyPortal {
- if preferences.mode == Constants.modes.intune || FileManager.default.fileExists(atPath: Constants.AppPaths.companyPortal) {
+ if preferences.mode == Constants.Modes.intune || FileManager.default.fileExists(atPath: Constants.AppPaths.companyPortal),
+ let companyPortalURL = URL(string: preferences.companyPortalUrl), !preferences.companyPortalUrl.isEmpty {
let companyPortalState = stateManager.getWebViewState(
for: "CompanyPortal",
- url: URL(string: "https://portal.manage.microsoft.com/")!
+ url: companyPortalURL
)
items.append(
SidebarItem(
@@ -103,10 +115,11 @@ func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateMa
}
// Add "Knowledge Base" with persistent WebViewState
- if preferences.menuShowKnowledgeBase && !preferences.knowledgeBaseUrl.isEmpty {
+ if preferences.menuShowKnowledgeBase,
+ let knowledgeBaseURL = URL(string: preferences.knowledgeBaseUrl), !preferences.knowledgeBaseUrl.isEmpty {
let knowledgeBaseState = stateManager.getWebViewState(
for: "KnowledgeBase",
- url: URL(string: preferences.knowledgeBaseUrl)!
+ url: knowledgeBaseURL
)
items.append(
SidebarItem(
@@ -116,6 +129,6 @@ func generateSidebarItems(preferences: Preferences, stateManager: WebViewStateMa
)
)
}
-
+
return items
}
diff --git a/SupportCompanion/App/SupportCompanion.swift b/SupportCompanion/App/SupportCompanion.swift
new file mode 100644
index 0000000..8a52f04
--- /dev/null
+++ b/SupportCompanion/App/SupportCompanion.swift
@@ -0,0 +1,16 @@
+import SwiftUI
+import Foundation
+import ServiceManagement
+
+@main
+struct SupportCompanion: App {
+
+ @State private var appStateManager = AppStateManager.shared
+ @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
+
+ var body: some Scene {
+ Settings {
+ EmptyView() // Use this to suppress unwanted UI elements like Preferences
+ }
+ }
+}
diff --git a/SupportCompanion/AppDelegate.swift b/SupportCompanion/AppDelegate.swift
deleted file mode 100644
index dfa5639..0000000
--- a/SupportCompanion/AppDelegate.swift
+++ /dev/null
@@ -1,342 +0,0 @@
-//
-// AppDelegate.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-14.
-//
-
-import Foundation
-import AppKit
-import UserNotifications
-import SwiftUI
-import Combine
-
-class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
- var popover: NSPopover!
- var statusItem: NSStatusItem?
- var windowController: NSWindowController?
- var transparentWindowController: TransparentWindowController?
- let appStateManager = AppStateManager.shared
- let elevationManager = ElevationManager.shared
- var mainWindow: NSWindow?
- static var urlLaunch = false
- static var shouldExit = false
- private var notificationDelegate: NotificationDelegate?
- private var cancellables: Set = []
- private var trayManager: TrayMenuManager { TrayMenuManager.shared }
-
- @AppStorage("isDarkMode") private var isDarkMode: Bool = false
-
- var hasUpdatesAvailable: Bool {
- appStateManager.pendingUpdatesCount > 0 || appStateManager.systemUpdateCache.count > 0
- }
-
- private func executeAction(_ action: Action) {
- Task {
- do {
- _ = try await ExecutionService.executeShellCommand(action.command, isPrivileged: action.isPrivileged)
- } catch {
- Logger.shared.logError("Failed to execute action: \(error)")
- }
- }
- }
-
- func application(_ application: NSApplication, open urls: [URL]) {
- guard let url = urls.first else { return }
-
- if url.host == "run" {
- Logger.shared.logDebug("Received run command request")
- if let queryItems = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems {
- if let actionName = queryItems.first(where: { $0.name == "action" })?.value {
- // Get the action details
- if let action = appStateManager.preferences.actions.first(where: { $0.name == actionName }) {
- Logger.shared.logDebug("Found action: \(action.name)")
- if action.isPrivileged ?? false && appStateManager.preferences.requirePrivilegedActionAuthentication {
- Logger.shared.logDebug("Action requires authentication")
- authenticateWithTouchIDOrPassword(completion: { success in
- if success {
- self.executeAction(action)
- } else {
- Logger.shared.logError("Authentication failed. Action: \(action.name) was not executed.")
- }
- }, reason: "authenticate to execute this privileged action.")
- } else {
- Logger.shared.logDebug("Executing action: \(action.name)")
- self.executeAction(action)
- }
- } else {
- Logger.shared.logError("Action not found: \(actionName)")
- }
- }
- }
- return
- }
-
- switch url.host?.lowercased() {
- case nil:
- AppDelegate.shouldExit = true
- if let statusItem = statusItem {
- Logger.shared.logDebug("Removing status item")
- NSStatusBar.system.removeStatusItem(statusItem)
- self.statusItem = nil
- }
- default:
- AppDelegate.shouldExit = false
- }
- AppDelegate.urlLaunch = true
- showWindow()
- NotificationCenter.default.post(name: .handleIncomingURL, object: url)
- }
-
- func applicationDidFinishLaunching(_ notification: Notification) {
- if !AppDelegate.shouldExit {
- setupTrayMenu()
- }
-
- popover = NSPopover()
- popover.behavior = .transient // Closes when clicking outside
- popover.contentSize = NSSize(width: 500, height: 500)
- popover.contentViewController = NSHostingController(
- rootView: TrayMenuView(
- viewModel: CardGridViewModel(appState: AppStateManager.shared)
- )
- .environmentObject(AppStateManager.shared)
- )
- popover.delegate = self
-
- configureAppUpdateNotificationCommand(mode: appStateManager.preferences.mode)
-
- appStateManager.showWindowCallback = { [weak self] in
- self?.showWindow()
- }
-
- if appStateManager.preferences.showDesktopInfo {
- // Initialize transparent window
- transparentWindowController = TransparentWindowController(appState: appStateManager)
- transparentWindowController?.showWindow(nil)
-
- // Make sure the transparent window is set up correctly
- if let window = NSApplication.shared.windows.first {
- window.isOpaque = false
- window.backgroundColor = .clear
- }
- }
-
- requestNotificationPermissions()
- notificationDelegate = NotificationDelegate()
- UNUserNotificationCenter.current().delegate = notificationDelegate
- appStateManager.startBackgroundTasks()
- appStateManager.refreshAll()
- checkAndHandleDemotionOnLaunch()
- }
-
- private func checkAndHandleDemotionOnLaunch() {
- if let endTime = elevationManager.loadPersistedDemotionState(), Date() >= endTime {
- elevationManager.demotePrivileges { success in
- if success {
- Logger.shared.logDebug("Privileges automatically demoted on app launch.")
- // Clear persisted state
- UserDefaults.standard.removeObject(forKey: "PrivilegeDemotionEndTime")
- } else {
- Logger.shared.logError("Failed to demote privileges on app launch.")
- }
- }
- } else if let endTime = elevationManager.loadPersistedDemotionState() {
- let remainingTime = endTime.timeIntervalSinceNow
- elevationManager.startDemotionTimer(duration: remainingTime) { remainingTime in
- DispatchQueue.main.async {
- AppStateManager.shared.timeToDemote = remainingTime
- AppStateManager.shared.isDemotionActive = remainingTime > 0
- }
- }
- }
- }
-
- private func setupTrayMenu() {
- let trayManager = TrayMenuManager.shared
- if statusItem == nil {
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
-
- setupTrayMenuIconBinding()
-
- if let button = trayManager.getStatusItem().button {
- button.action = #selector(togglePopover)
- button.target = self
- }
- }
- }
-
- func setupTrayMenuIconBinding() {
- Publishers.CombineLatest4(
- appStateManager.$pendingUpdatesCount,
- appStateManager.$systemUpdateCache,
- appStateManager.preferences.$hiddenActions,
- appStateManager.preferences.$hiddenCards
- )
- .map { pendingUpdatesCount, systemUpdateCache, hiddenActions, hiddenCards in
- let hasPendingUpdates = !hiddenCards.contains("PendingAppUpdates") && pendingUpdatesCount > 0
- let hasSoftwareUpdates = !hiddenActions.contains("SoftwareUpdates") && systemUpdateCache.count > 0
- return hasPendingUpdates || hasSoftwareUpdates
- }
- .sink { hasUpdates in
- TrayMenuManager.shared.updateTrayIcon(hasUpdates: hasUpdates)
- }
- .store(in: &cancellables)
- }
-
- class TrayMenuManager {
- static let shared = TrayMenuManager()
- let appStateManager = AppStateManager.shared
- let fileManager = FileManager.default
- private var statusItem: NSStatusItem
-
- private init() {
- statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
- updateTrayIcon(hasUpdates: false) // Default state
- }
-
- func updateTrayIcon(hasUpdates: Bool) {
- let iconName = "MenuIcon"
- let base64Logo = appStateManager.preferences.trayMenuBrandingIcon
- var showLogo = false
- var baseIcon: NSImage?
-
- showLogo = loadLogo(base64Logo: base64Logo)
- if showLogo {
- baseIcon = NSImage(data: Data(base64Encoded: base64Logo)!)
- } else {
- baseIcon = NSImage(named: iconName)
- }
-
- guard let baseIcon = baseIcon else {
- Logger.shared.logError("Error: Failed to load tray menu icon")
- return
- }
-
- baseIcon.size = NSSize(width: 16, height: 16)
- baseIcon.isTemplate = true // Ensure base icon respects system appearance
-
- if let button = statusItem.button {
- // Clear any existing layers
- button.layer?.sublayers?.forEach { $0.removeFromSuperlayer() }
-
- // Set the base icon as the button's image
- button.image = baseIcon
- button.image?.isTemplate = true
-
- if hasUpdates {
- Logger.shared.logDebug("Updates available, adding badge to tray icon")
-
- // Add badge dynamically as a layer
- let badgeLayer = CALayer()
- badgeLayer.backgroundColor = NSColor.red.cgColor
- badgeLayer.frame = CGRect(
- x: button.bounds.width - 15, // Align to the lower-right corner
- y: 10, // Small offset from the bottom
- width: 8,
- height: 8
- )
- badgeLayer.cornerRadius = 4 // Make it circular
-
- // Ensure button has a layer to add sublayers
- if button.layer == nil {
- button.wantsLayer = true
- button.layer = CALayer()
- }
-
- button.layer?.addSublayer(badgeLayer)
- }
- }
- }
-
- func getStatusItem() -> NSStatusItem {
- return statusItem
- }
- }
-
- @objc private func togglePopover() {
- guard let button = trayManager.getStatusItem().button else {
- Logger.shared.logError("Error: TrayMenuManager's statusItem.button is nil")
- return
- }
-
- if popover.isShown {
- popover.performClose(nil)
- } else {
- // Dynamically set the popover content
- popover.contentViewController = NSHostingController(
- rootView: TrayMenuView(
- viewModel: CardGridViewModel(appState: AppStateManager.shared)
- )
- .environmentObject(AppStateManager.shared)
- )
-
- // Anchor the popover to the status item's button
- popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
-
- // Ensure the popover window is brought to the front
- if let popoverWindow = popover.contentViewController?.view.window {
- popoverWindow.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
- }
- }
-
- func popoverDidClose(_ notification: Notification) {
- Logger.shared.logDebug("Popover closed, cleaning up...")
-
- // Cleanup logic: release the popover or its content
- popover.contentViewController = nil
- }
-
- @objc func showWindow() {
- if windowController == nil {
- NSApp.setActivationPolicy(.regular)
- let contentView = ContentView()
- .environmentObject(AppStateManager.shared)
- .environmentObject(Preferences())
- .frame(minWidth: 1100, minHeight: 650)
-
- let hostingController = NSHostingController(rootView: contentView)
-
- let window = NSWindow(contentViewController: hostingController)
- window.setContentSize(NSSize(width: 1500, height: 1020))
- window.styleMask = [.titled, .closable, .resizable]
- window.minSize = NSSize(width: 1100, height: 650)
- window.title = ""
- window.isReleasedWhenClosed = false
- window.backgroundColor = .clear
- window.titlebarAppearsTransparent = true
- window.styleMask.insert(.fullSizeContentView)
- window.center()
- window.level = .normal
-
- // Assign a delegate to handle window lifecycle
- window.delegate = self
-
- windowController = NSWindowController(window: window)
- }
-
- windowController?.showWindow(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
-
- @objc private func runAction(_ sender: NSMenuItem) {
- guard let action = sender.representedObject as? Action else { return }
- Task {
- _ = try await ExecutionService.executeShellCommand(action.command, isPrivileged: action.isPrivileged)
- }
- }
-
- @objc private func quitApp() {
- NSApplication.shared.terminate(nil)
- }
-
- private func configureAppUpdateNotificationCommand(mode: String) {
- if mode == "Munki" {
- appStateManager.preferences.appUpdateNotificationCommand = "open \(Constants.AppPaths.MSCUpdates)"
- } else {
- appStateManager.preferences.appUpdateNotificationCommand = "open \(Constants.AppPaths.companyPortal)"
- }
- }
-}
diff --git a/SupportCompanion/Components/CardData.swift b/SupportCompanion/Components/CardData.swift
index 8fefe66..2ecde0f 100644
--- a/SupportCompanion/Components/CardData.swift
+++ b/SupportCompanion/Components/CardData.swift
@@ -30,7 +30,9 @@ struct CardData: View {
VStack(alignment: .leading, spacing: 5) {
// Display the key-value pair with formatting based on the key
defaultContent(for: key, display: display, value: value)
-
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(display) \(value.displayValue)")
+
// Insert custom content for specific labels
customContent(key, value)
}
diff --git a/SupportCompanion/Constants.swift b/SupportCompanion/Constants.swift
index a00433f..d5f9539 100644
--- a/SupportCompanion/Constants.swift
+++ b/SupportCompanion/Constants.swift
@@ -8,103 +8,543 @@
import Foundation
enum Constants {
-
+
enum Support {
enum Titles {
- static let support = String(localized: "Support.Support", defaultValue: "Support", comment: "Support title")
+ static let support = String(
+ localized: "Support.Support", defaultValue: "Support", comment: "Support title")
}
enum Labels {
- static let phone = String(localized: "Support.Phone", defaultValue: "Phone:", comment: "Phone number")
- static let email = String(localized: "Support.Email", defaultValue: "Email:", comment: "Email address")
+ static let phone = String(
+ localized: "Support.Phone", defaultValue: "Phone:", comment: "Phone number")
+ static let email = String(
+ localized: "Support.Email", defaultValue: "Email:", comment: "Email address")
}
enum Keys {
static let phone = "SupportPhone"
static let email = "SupportEmail"
}
}
-
- enum modes {
+
+ enum Modes {
static let munki = "Munki"
static let intune = "Intune"
static let systemProfiler = "SystemProfiler"
+ static let jamf = "Jamf"
+ static let fleet = "Fleet"
+ }
+
+ /// Wording the Apps page shares with the Fleet catalog.
+ ///
+ /// Aliases rather than keys of their own: the text is word for word the same, and a second set of
+ /// keys would mean translating "Installed" into five languages twice and keeping both in step.
+ enum Apps {
+ static var searchPlaceholder: String { Fleet.searchPlaceholder }
+ static var clearSearch: String { Fleet.clearSearch }
+ static var updatesAvailable: String { Fleet.updatesAvailable }
+ static var installed: String { Fleet.installed }
+ static var noMatches: String { Fleet.noMatches }
}
-
+
+ enum Fleet {
+ static let searchPlaceholder = String(
+ localized: "Fleet.SearchPlaceholder", defaultValue: "Search apps",
+ comment: "Placeholder for the Fleet app catalog search field")
+ static let clearSearch = String(
+ localized: "Fleet.ClearSearch", defaultValue: "Clear search",
+ comment: "Button that empties the app catalog search field")
+ static let allCategories = String(
+ localized: "Fleet.AllCategories", defaultValue: "All",
+ comment: "Category filter showing every app")
+ static let updatesAvailable = String(
+ localized: "Fleet.UpdatesAvailable", defaultValue: "Updates Available",
+ comment: "Section of apps with a newer version available")
+ static let available = String(
+ localized: "Fleet.Available", defaultValue: "Available",
+ comment: "Section of apps that can be installed")
+ static let installed = String(
+ localized: "Fleet.Installed", defaultValue: "Installed",
+ comment: "Section of installed apps, and the installed status badge")
+ static let installing = String(
+ localized: "Fleet.Installing", defaultValue: "Installing…",
+ comment: "Status badge while an app installs")
+ static let uninstalling = String(
+ localized: "Fleet.Uninstalling", defaultValue: "Uninstalling…",
+ comment: "Status badge while an app uninstalls")
+ static let installFailed = String(
+ localized: "Fleet.InstallFailed", defaultValue: "Install failed",
+ comment: "Status badge when an install failed")
+ static let uninstallFailed = String(
+ localized: "Fleet.UninstallFailed", defaultValue: "Uninstall failed",
+ comment: "Status badge when an uninstall failed")
+ static let updateAvailable = String(
+ localized: "Fleet.UpdateAvailable", defaultValue: "Update available",
+ comment: "Status badge when a newer version is available")
+ static let loading = String(
+ localized: "Fleet.Loading", defaultValue: "Loading apps…",
+ comment: "Shown while the Fleet catalog loads")
+ static let noApps = String(
+ localized: "Fleet.NoApps", defaultValue: "No apps are available for this Mac.",
+ comment: "Shown when the Fleet catalog is empty")
+ static let noMatches = String(
+ localized: "Fleet.NoMatches", defaultValue: "No apps match your search.",
+ comment: "Shown when search or category filters hide every app")
+ static let notConfigured = String(
+ localized: "Fleet.NotConfigured", defaultValue: "Fleet isn't set up on this Mac.",
+ comment: "Shown when orbit or the Fleet server URL is missing")
+ static let signInRequired = String(
+ localized: "Fleet.SignInRequired",
+ defaultValue: "Sign in to see the apps available for this Mac.",
+ comment: "Shown when Fleet requires single sign-on")
+ static let signIn = String(
+ localized: "Fleet.SignIn", defaultValue: "Sign In",
+ comment: "Button that starts Fleet single sign-on")
+ static let couldNotLoad = String(
+ localized: "Fleet.CouldNotLoad", defaultValue: "Couldn't load apps",
+ comment: "Title when the Fleet catalog failed to load")
+ static let couldNotRefresh = String(
+ localized: "Fleet.CouldNotRefresh", defaultValue: "Couldn't refresh",
+ comment: "Shown next to a catalog that failed to refresh")
+ static let retry = String(
+ localized: "Fleet.Retry", defaultValue: "Try Again",
+ comment: "Button to reload the Fleet catalog")
+ static let version = String(
+ localized: "Fleet.Version", defaultValue: "Version",
+ comment: "Label for an app's installed version")
+ static let latestVersion = String(
+ localized: "Fleet.LatestVersion", defaultValue: "Latest",
+ comment: "Label for the version available from Fleet")
+ static let install = String(
+ localized: "Fleet.Install", defaultValue: "Install",
+ comment: "Button that installs an app from Fleet")
+ static let update = String(
+ localized: "Fleet.Update", defaultValue: "Update",
+ comment: "Button that updates an app from Fleet")
+ static let policiesPassing = String(
+ localized: "Fleet.PoliciesPassing", defaultValue: "All %d checks are passing.",
+ comment: "Compliance card when every policy passes; %d is the number of checks")
+ static let policiesFailing = String(
+ localized: "Fleet.PoliciesFailing", defaultValue: "%d of %d checks need attention",
+ comment: "Compliance card summary; the numbers are failing and total checks")
+ static let noPolicies = String(
+ localized: "Fleet.NoPolicies",
+ defaultValue: "There are no compliance checks for this Mac.",
+ comment: "Compliance card without policies")
+ static let policiesSignIn = String(
+ localized: "Fleet.PoliciesSignIn",
+ defaultValue: "Sign in on the Apps page to see compliance checks.",
+ comment: "Compliance card when Fleet requires single sign-on")
+ static let policiesUnavailable = String(
+ localized: "Fleet.PoliciesUnavailable",
+ defaultValue: "Couldn't load compliance checks.",
+ comment: "Compliance card when policies couldn't be loaded")
+ static let passingChecks = String(
+ localized: "Fleet.PassingChecks", defaultValue: "Passing checks (%d)",
+ comment: "Heading above the list of passing Fleet policies; %d is the count")
+ static let lastChecked = String(
+ localized: "Fleet.LastChecked", defaultValue: "Checked %@",
+ comment: "When compliance last ran; %@ is a relative time such as '5 minutes ago'")
+ static let critical = String(
+ localized: "Fleet.Critical", defaultValue: "Critical",
+ comment: "Badge for a critical Fleet policy")
+ static let howToFix = String(
+ localized: "Fleet.HowToFix", defaultValue: "How to fix",
+ comment: "Disclosure that shows how to resolve a failing policy")
+ static let policyFailingNotification = String(
+ localized: "Fleet.PolicyFailingNotification",
+ defaultValue: "Your Mac needs attention: %@",
+ comment: "Notification when a policy starts failing; %@ is the policy name")
+ static let policiesFailingNotification = String(
+ localized: "Fleet.PoliciesFailingNotification",
+ defaultValue: "Your Mac needs attention: %d checks are failing, including %@.",
+ comment:
+ "Notification when several policies start failing; %d is the count, %@ a policy name"
+ )
+ static let viewDetails = String(
+ localized: "Fleet.ViewDetails", defaultValue: "View Details",
+ comment: "Notification button that opens Support Companion")
+ static let waitingForAppToClose = String(
+ localized: "Fleet.WaitingForAppToClose", defaultValue: "Waiting for app to close",
+ comment: "Status badge when an install didn't run because the app was open")
+ static let appOpenMessage = String(
+ localized: "Fleet.AppOpenMessage", defaultValue: "%@ is open. Quit it to finish.",
+ comment: "Card message when an install needs the app closed; %@ is the app name")
+ static let appClosedMessage = String(
+ localized: "Fleet.AppClosedMessage",
+ defaultValue: "%@ was open, so it wasn't changed. Try again now that it's closed.",
+ comment:
+ "Card message after the app that blocked an install was closed; %@ is the app name")
+ static let quitAndUpdate = String(
+ localized: "Fleet.QuitAndUpdate", defaultValue: "Quit & Update",
+ comment: "Button that quits an app and installs its update")
+ static let quitAndInstall = String(
+ localized: "Fleet.QuitAndInstall", defaultValue: "Quit & Install",
+ comment: "Button that quits an app and installs it again")
+ static let couldNotQuit = String(
+ localized: "Fleet.CouldNotQuit",
+ defaultValue: "%@ didn't quit. Save your work, quit it, and try again.",
+ comment: "Shown when an app didn't quit; %@ is the app name")
+ static let appOpenNotification = String(
+ localized: "Fleet.AppOpenNotification", defaultValue: "Quit %@ to finish updating it.",
+ comment: "Notification when an install needs the app closed; %@ is the app name")
+ static let recommended = String(
+ localized: "Fleet.Recommended", defaultValue: "Recommended",
+ comment: "Section of apps IT recommends installing")
+ static let reinstall = String(
+ localized: "Fleet.Reinstall", defaultValue: "Reinstall",
+ comment: "Button that installs an installed app again through Fleet")
+ static let reinstalledNotification = String(
+ localized: "Fleet.ReinstalledNotification", defaultValue: "%@ was reinstalled.",
+ comment: "Notification after a reinstall; %@ is the app name")
+ static let viewApps = String(
+ localized: "Fleet.ViewApps", defaultValue: "View Apps",
+ comment: "Button that shows the Apps page in Support Companion")
+ static let viewUpdates = String(
+ localized: "Fleet.ViewUpdates", defaultValue: "View Updates",
+ comment: "Button that shows available app updates in Support Companion")
+ static let complianceDetails = String(
+ localized: "Fleet.ComplianceDetails", defaultValue: "Details",
+ comment: "Button that opens Support Companion to show compliance details")
+ static let uninstall = String(
+ localized: "Fleet.Uninstall", defaultValue: "Uninstall",
+ comment: "Button that uninstalls an app through Fleet")
+ static let availableInCatalog = String(
+ localized: "UserInstalls.AvailableInCatalog",
+ defaultValue: "Your organisation already offers this",
+ comment: "Shown when a refused installer matches something in the software catalog")
+ static let availableInCatalogDetail = String(
+ localized: "UserInstalls.AvailableInCatalogDetail",
+ defaultValue: "Install the approved version from %@ instead of this download.",
+ comment: "Explains the catalog suggestion; %@ is the name of the Apps page")
+ static let showInCatalog = String(
+ localized: "UserInstalls.ShowInCatalog", defaultValue: "Show me",
+ comment: "Button taking the user to the software catalog page")
+ static let details = String(
+ localized: "Fleet.Details", defaultValue: "Details",
+ comment: "Button that shows the output of a failed install or uninstall")
+ static let moreActions = String(
+ localized: "Fleet.MoreActions", defaultValue: "More actions",
+ comment: "Help text for the menu with extra actions for an app")
+ static let cancel = String(
+ localized: "Fleet.Cancel", defaultValue: "Cancel", comment: "Cancel button")
+ static let ssoSheetTitle = String(
+ localized: "Fleet.SSOSheetTitle", defaultValue: "Sign in to Fleet",
+ comment: "Title of the Fleet single sign-on window")
+ static let ssoConnecting = String(
+ localized: "Fleet.SSOConnecting", defaultValue: "Connecting to your identity provider\u{2026}",
+ comment: "Shown while the Fleet sign-in page is being prepared")
+ static let ssoCouldNotSignIn = String(
+ localized: "Fleet.SSOCouldNotSignIn", defaultValue: "Couldn't sign in",
+ comment: "Title when Fleet single sign-on failed")
+ static let ssoDisabled = String(
+ localized: "Fleet.SSODisabled",
+ defaultValue: "Single sign-on for Fleet isn't enabled. Contact your IT department.",
+ comment: "Shown when Fleet Desktop single sign-on was turned off during sign-in")
+ static let ssoFailed = String(
+ localized: "Fleet.SSOFailed",
+ defaultValue: "Your identity provider didn't complete the sign-in. Try again.",
+ comment: "Shown when the identity provider didn't return a valid Fleet session")
+ static let signInNotification = String(
+ localized: "Fleet.SignInNotification",
+ defaultValue: "Sign in to Fleet to see your apps and compliance checks.",
+ comment: "Notification when Fleet needs the user to sign in")
+ static let signInNotificationFailing = String(
+ localized: "Fleet.SignInNotificationFailing",
+ defaultValue: "%d compliance checks need attention. Sign in to see which.",
+ comment: "Notification when a signed-out Mac has failing checks; %d is how many")
+ static let signedOutRecord = String(
+ localized: "Fleet.SignedOutRecord",
+ defaultValue: "Sign in to see this Mac's record in Fleet.",
+ comment: "Fleet info card when the user hasn't signed in")
+ static let signedOutFailing = String(
+ localized: "Fleet.SignedOutFailing",
+ defaultValue: "%d checks need attention. Sign in to see which.",
+ comment: "Compliance summary while signed out; %d is the number of failing checks")
+ static let signedOutPassing = String(
+ localized: "Fleet.SignedOutPassing", defaultValue: "No checks are failing.",
+ comment: "Compliance summary while signed out when nothing is failing")
+ static let signInForChecks = String(
+ localized: "Fleet.SignInForChecks", defaultValue: "Sign in to see compliance checks.",
+ comment: "Compliance page when Fleet requires single sign-on")
+ static let close = String(
+ localized: "Fleet.Close", defaultValue: "Close",
+ comment: "Button that closes the install details")
+ static let uninstallConfirmTitle = String(
+ localized: "Fleet.UninstallConfirmTitle", defaultValue: "Uninstall %@?",
+ comment: "Title asking to confirm an uninstall; %@ is the app name")
+ static let uninstallConfirmMessage = String(
+ localized: "Fleet.UninstallConfirmMessage",
+ defaultValue: "The app will be removed from this Mac.",
+ comment: "Message asking to confirm an uninstall")
+ static let installDetailsTitle = String(
+ localized: "Fleet.InstallDetailsTitle", defaultValue: "Install details",
+ comment: "Title of the sheet showing install output")
+ static let uninstallDetailsTitle = String(
+ localized: "Fleet.UninstallDetailsTitle", defaultValue: "Uninstall details",
+ comment: "Title of the sheet showing uninstall output")
+ static let noOutput = String(
+ localized: "Fleet.NoOutput", defaultValue: "Fleet didn't report any output.",
+ comment: "Shown when a failed install has no output")
+ static let couldNotLoadDetails = String(
+ localized: "Fleet.CouldNotLoadDetails", defaultValue: "Couldn't load details",
+ comment: "Shown when install output couldn't be fetched")
+ static let installedNotification = String(
+ localized: "Fleet.InstalledNotification", defaultValue: "%@ was installed.",
+ comment: "Notification after an install; %@ is the app name")
+ static let updatedNotification = String(
+ localized: "Fleet.UpdatedNotification", defaultValue: "%@ was updated.",
+ comment: "Notification after an update; %@ is the app name")
+ static let uninstalledNotification = String(
+ localized: "Fleet.UninstalledNotification", defaultValue: "%@ was uninstalled.",
+ comment: "Notification after an uninstall; %@ is the app name")
+ static let installFailedNotification = String(
+ localized: "Fleet.InstallFailedNotification", defaultValue: "%@ couldn't be installed.",
+ comment: "Notification after a failed install or update; %@ is the app name")
+ static let uninstallFailedNotification = String(
+ localized: "Fleet.UninstallFailedNotification",
+ defaultValue: "%@ couldn't be uninstalled.",
+ comment: "Notification after a failed uninstall; %@ is the app name")
+ }
+
enum TrayMenu {
- static let openApp = String(localized: "TrayMenu.OpenApp", defaultValue: "Open Support Companion", comment: "Open the app")
- static let quitApp = String(localized: "TrayMenu.QuitApp", defaultValue: "Quit", comment: "Quit the app")
+ static let openApp = String(
+ localized: "TrayMenu.OpenApp", defaultValue: "Open Support Companion",
+ comment: "Open the app")
+ static let quitApp = String(
+ localized: "TrayMenu.QuitApp", defaultValue: "Quit", comment: "Quit the app")
}
-
+
enum General {
- static let days = String(localized: "General.Days", defaultValue: "Days", comment: "Number of days")
- static let day = String(localized: "General.Day", defaultValue: "Day", comment: "Number of day")
- static let dayAgo = String(localized: "General.DayAgo", defaultValue: "Day Ago", comment: "Number of day ago")
- static let daysAgo = String(localized: "General.DaysAgo", defaultValue: "Days Ago", comment: "Number of days ago")
- static let hours = String(localized: "General.Hours", defaultValue: "Hours", comment: "Number of hours")
- static let hour = String(localized: "General.Hour", defaultValue: "Hour", comment: "Number of hour")
- static let minute = String(localized: "General.Minute", defaultValue: "Minute", comment: "Number of minute")
- static let minutes = String(localized: "General.Minutes", defaultValue: "Minutes", comment: "Number of minutes")
- static let second = String(localized: "General.Second", defaultValue: "Second", comment: "Number of second")
- static let seconds = String(localized: "General.Seconds", defaultValue: "Seconds", comment: "Number of seconds")
- static let manage = String(localized: "General.Manage", defaultValue: "Manage", comment: "Manage")
- static let close = String(localized: "General.Close", defaultValue: "Close", comment: "Close")
- static let elevate = String(localized: "General.Elevate", defaultValue: "Elevate", comment: "Elevate")
- static let demote: String = String(localized: "General.Demote", defaultValue: "Demote", comment: "Demote")
+ static let days = String(
+ localized: "General.Days", defaultValue: "Days", comment: "Number of days")
+ static let day = String(
+ localized: "General.Day", defaultValue: "Day", comment: "Number of day")
+ static let dayAgo = String(
+ localized: "General.DayAgo", defaultValue: "Day Ago", comment: "Number of day ago")
+ static let daysAgo = String(
+ localized: "General.DaysAgo", defaultValue: "Days Ago", comment: "Number of days ago")
+ static let hours = String(
+ localized: "General.Hours", defaultValue: "Hours", comment: "Number of hours")
+ static let hour = String(
+ localized: "General.Hour", defaultValue: "Hour", comment: "Number of hour")
+ static let minute = String(
+ localized: "General.Minute", defaultValue: "Minute", comment: "Number of minute")
+ static let minutes = String(
+ localized: "General.Minutes", defaultValue: "Minutes", comment: "Number of minutes")
+ static let second = String(
+ localized: "General.Second", defaultValue: "Second", comment: "Number of second")
+ static let seconds = String(
+ localized: "General.Seconds", defaultValue: "Seconds", comment: "Number of seconds")
+ static let manage = String(
+ localized: "General.Manage", defaultValue: "Manage", comment: "Manage")
+ static let close = String(
+ localized: "General.Close", defaultValue: "Close", comment: "Close")
+ static let elevate = String(
+ localized: "General.Elevate", defaultValue: "Elevate", comment: "Elevate")
+ static let demote: String = String(
+ localized: "General.Demote", defaultValue: "Demote", comment: "Demote")
+ static let ago: String = String(
+ localized: "General.Ago", defaultValue: "Ago", comment: "Ago")
+ static let justNow: String = String(
+ localized: "General.JustNow", defaultValue: "Just Now", comment: "Just Now")
+ static let cancel = String(
+ localized: "General.Cancel", defaultValue: "Cancel", comment: "Cancel")
+ static let done = String(
+ localized: "General.Done", defaultValue: "Done", comment: "Done")
+ }
+
+ /// The window shown when a user opens an installer an organisation may have allowed.
+ enum UserInstalls {
+ static let checking = String(
+ localized: "UserInstalls.Checking", defaultValue: "Checking this installer",
+ comment: "Shown while the helper assesses an installer the user opened")
+ static let installing = String(
+ localized: "UserInstalls.Installing", defaultValue: "Installing",
+ comment: "Shown while an allowed installer is being installed")
+ static let installed = String(
+ localized: "UserInstalls.Installed", defaultValue: "Installed successfully",
+ comment: "Shown when an allowed installer has finished installing")
+ static let failed = String(
+ localized: "UserInstalls.Failed", defaultValue: "The installation did not finish",
+ comment: "Shown when an allowed installer failed to install")
+ static let install = String(
+ localized: "UserInstalls.Install", defaultValue: "Install",
+ comment: "Button that installs an allowed application")
+ static let allowedByAdministrator = String(
+ localized: "UserInstalls.AllowedByAdministrator",
+ defaultValue: "Your administrator allows you to install this",
+ comment: "Shown when an installer matches the administrator's allowlist")
+ static let notAllowed = String(
+ localized: "UserInstalls.NotAllowed",
+ defaultValue: "Your organisation has not allowed this installer",
+ comment: "Shown when an installer does not match the organisation's allowlist")
+ static let elevateInstead = String(
+ localized: "UserInstalls.ElevateInstead", defaultValue: "Request admin rights",
+ comment: "Button offering the elevation flow for an installer that is not allowed")
+ static let version = String(
+ localized: "UserInstalls.Version", defaultValue: "Version",
+ comment: "Precedes the version number of an installer in the install window")
+ static let kindPackage = String(
+ localized: "UserInstalls.KindPackage", defaultValue: "Installer package",
+ comment: "Describes a .pkg in the install window")
+ static let kindApplication = String(
+ localized: "UserInstalls.KindApplication", defaultValue: "Application",
+ comment: "Describes an app from a .dmg in the install window")
+ static let availableInCatalog = String(
+ localized: "UserInstalls.AvailableInCatalog",
+ defaultValue: "Your organisation already offers this",
+ comment: "Shown when a refused installer matches something in the software catalog")
+ static let availableInCatalogDetail = String(
+ localized: "UserInstalls.AvailableInCatalogDetail",
+ defaultValue: "Install the approved version from %@ instead of this download.",
+ comment: "Explains the catalog suggestion; %@ is the name of the Apps page")
+ static let showInCatalog = String(
+ localized: "UserInstalls.ShowInCatalog", defaultValue: "Show me",
+ comment: "Button taking the user to the software catalog page")
+ static let details = String(
+ localized: "UserInstalls.Details", defaultValue: "Details",
+ comment: "Collapsed section holding what an organisation needs to allow an installer")
+ static let certificate = String(
+ localized: "UserInstalls.Certificate", defaultValue: "Signing certificate SHA-256",
+ comment: "Label for the digest of the certificate an installer was signed with")
+ static let identifier = String(
+ localized: "UserInstalls.Identifier", defaultValue: "Identifier",
+ comment: "Label for a refused installer's package or bundle identifier")
+ static let installsTo = String(
+ localized: "UserInstalls.InstallsTo", defaultValue: "Installs to",
+ comment: "Label for the places an installer writes")
+ static let fingerprint = String(
+ localized: "UserInstalls.Fingerprint", defaultValue: "SHA-256 of",
+ comment: "Precedes a file name, above that file's digest, for a refused installer")
+ static let developer = String(
+ localized: "UserInstalls.Developer", defaultValue: "Developer",
+ comment: "Label for who signed the installer")
+ static let verification = String(
+ localized: "UserInstalls.Verification", defaultValue: "Verified",
+ comment: "Label for how the installer was verified")
+ static let allowedAs = String(
+ localized: "UserInstalls.AllowedAs", defaultValue: "Allowed as",
+ comment: "Label for which allowlist entry matched")
+ static let verifiedByDigest = String(
+ localized: "UserInstalls.VerifiedByDigest",
+ defaultValue: "Exact copy your organisation approved",
+ comment: "Shown when an installer matched on its SHA-256")
+ static let verifiedNotarized = String(
+ localized: "UserInstalls.VerifiedNotarized",
+ defaultValue: "Signature and Apple notarization",
+ comment: "Shown when an installer matched on a notarized signature")
+ static let openInInstaller = String(
+ localized: "UserInstalls.OpenInInstaller", defaultValue: "Open Anyway",
+ comment:
+ "Button handing the installer to Installer.app after the check could not be made")
+ static let verifiedSignature = String(
+ localized: "UserInstalls.VerifiedSignature", defaultValue: "Developer signature",
+ comment: "Shown when an installer matched on its signature alone")
}
-
+
enum AppPaths {
static let companyPortal = "/Applications/Company Portal.app"
static let MSC = "/Applications/Managed Software Center.app"
static let MSCUpdates = "munki://updates.html"
+ static let selfService = "/Applications/Self Service+.app"
}
-
+
enum Paths {
static let tempArchivePath = "/tmp/supportcompanion_logs.zip"
+ static let jamfSelfServiceData =
+ "~/Library/Application Support/osx-self-service.Self-Service.resources/CocoaAppCD.storedata"
}
-
+
enum Panels {
static let storage = "x-apple.systempreferences:com.apple.settings.Storage"
- static let softwareUpdates = "x-apple.systempreferences:com.apple.preferences.softwareupdate"
+ static let softwareUpdates =
+ "x-apple.systempreferences:com.apple.preferences.softwareupdate"
static let users = "x-apple.systempreferences:com.apple.preferences.users"
+ static let backgroundSecurityImprovements =
+ "x-apple.systempreferences:com.apple.SecurityImprovements-Settings.extension"
}
-
+
enum ToolTips {
- static let deviceInfoCopy = String(localized: "ToolTip.DeviceInfoCopy", defaultValue: "Copy device information to clipboard", comment: "Tooltip text when copying device information to clipboard")
- static let openStoragePanel = String(localized: "ToolTip.OpenStoragePanel", defaultValue: "Open storage panel", comment: "Tooltip text when opening the storage panel")
- static let deviceLastRebooted = String(localized: "ToolTip.DeviceLastRebooted", defaultValue: "Regularly rebooting your device can enhance its performance and longevity by clearing temporary files and freeing up system resources.", comment: "Tooltip text when showing reboot information")
+ static let deviceInfoCopy = String(
+ localized: "ToolTip.DeviceInfoCopy",
+ defaultValue: "Copy device information to clipboard",
+ comment: "Tooltip text when copying device information to clipboard")
+ static let openStoragePanel = String(
+ localized: "ToolTip.OpenStoragePanel", defaultValue: "Open storage panel",
+ comment: "Tooltip text when opening the storage panel")
+ static let deviceLastRebooted = String(
+ localized: "ToolTip.DeviceLastRebooted",
+ defaultValue:
+ "Regularly rebooting your device can enhance its performance and longevity by clearing temporary files and freeing up system resources.",
+ comment: "Tooltip text when showing reboot information")
}
-
+
enum Notifications {
enum SoftwareUpdate {
- static let UpdateNotificationMessage = String(localized: "Notification.UpdateAvailable", defaultValue: "Software Updates Available. Please update your device to the latest version.", comment: "Notification message when an update is available")
- static let UpdateNotificationButtonText = String(localized: "Notification.UpdateNow", defaultValue: "Update Now 🚀", comment: "Notification button text when an update is available")
+ static let UpdateNotificationMessage = String(
+ localized: "Notification.UpdateAvailable",
+ defaultValue:
+ "Software Updates Available. Please update your device to the latest version.",
+ comment: "Notification message when an update is available")
+ static let UpdateNotificationButtonText = String(
+ localized: "Notification.UpdateNow", defaultValue: "Update Now 🚀",
+ comment: "Notification button text when an update is available")
}
-
+
enum AppUpdate {
- static let UpdateNotificationMessage = String(localized: "Notification.AppUpdateAvailable", defaultValue: "App Updates Available. Please update your apps to the latest version.", comment: "Notification message when an update is available")
- static let UpdateNotificationButtonText = String(localized: "Notification.UpdateNow", defaultValue: "Update Now 🚀", comment: "Notification button text when an update is available")
+ static let UpdateNotificationMessage = String(
+ localized: "Notification.AppUpdateAvailable",
+ defaultValue:
+ "App Updates Available. Please update your apps to the latest version.",
+ comment: "Notification message when an update is available")
+ static let UpdateNotificationButtonText = String(
+ localized: "Notification.UpdateNow", defaultValue: "Update Now 🚀",
+ comment: "Notification button text when an update is available")
}
enum Elevation {
- static let ElevationStartedMessage = String(localized: "Notification.ElevationStarted", defaultValue: "Privliged session started. You will be demoted in", comment: "Notification message when an elevation is started")
- static let ElevationHalfwayMessage = String(localized: "Notification.ElevationHalfway", defaultValue: "Your elevated privileges will be demoted in", comment: "Notification message when half the time has passed")
- static let ElevationDemotedMessage = String(localized: "Notification.ElevationDemoted", defaultValue: "Your elevated privileges have been demoted.", comment: "Notification message when the elevation is demoted")
+ static let ElevationStartedMessage = String(
+ localized: "Notification.ElevationStarted",
+ defaultValue: "Privileged session started. You will be demoted in",
+ comment: "Notification message when an elevation is started")
+ static let ElevationHalfwayMessage = String(
+ localized: "Notification.ElevationHalfway",
+ defaultValue: "Your elevated privileges will be demoted in",
+ comment: "Notification message when half the time has passed")
+ static let ElevationDemotedMessage = String(
+ localized: "Notification.ElevationDemoted",
+ defaultValue: "Your elevated privileges have been demoted.",
+ comment: "Notification message when the elevation is demoted")
}
enum Reboot {
- static let RebootMessage = String(localized: "Notification.RebootReminder", defaultValue: "Your device was last restarted %lld %@ ago. Please reboot your device to ensure optimal performance and security.", comment: "Notification message reminding the user to reboot their device")
+ static let RebootMessage = String(
+ localized: "Notification.RebootReminder",
+ defaultValue:
+ "Your device was last restarted %lld %@ ago. Please reboot your device to ensure optimal performance and security.",
+ comment: "Notification message reminding the user to reboot their device")
}
}
-
+
enum Errors {
- static let noInternetConnection = String(localized: "Error.NoInternetConnection", defaultValue: "No internet connection is available.", comment: "Error message when no internet connection is available")
- static let invalidRealmSSO = String(localized: "Error.InvalidRealm", defaultValue: "Invalid SSO REALM detected.", comment: "Error message when the realm is invalid")
- static let commandFailedSSO = String(localized: "Error.CommandFailed", defaultValue: "Failed to execute the SSO command.", comment: "Error message when a command failed")
+ static let noInternetConnection = String(
+ localized: "Error.NoInternetConnection",
+ defaultValue: "No internet connection is available.",
+ comment: "Error message when no internet connection is available")
+ static let invalidRealmSSO = String(
+ localized: "Error.InvalidRealm", defaultValue: "Invalid SSO REALM detected.",
+ comment: "Error message when the realm is invalid")
+ static let commandFailedSSO = String(
+ localized: "Error.CommandFailed", defaultValue: "Failed to execute the SSO command.",
+ comment: "Error message when a command failed")
}
-
+
enum Titles {
- static let saveLogs = String(localized: "Title.SaveLogs", defaultValue: "Save Logs", comment: "Title for save logs dialog")
+ static let saveLogs = String(
+ localized: "Title.SaveLogs", defaultValue: "Save Logs",
+ comment: "Title for save logs dialog")
}
-
+
enum Cards {
static let storage = "Storage"
static let actions = "Actions"
@@ -114,38 +554,100 @@ enum Constants {
static let appPatchProgress = "ApplicationInstallProgress"
static let battery = "Battery"
static let pendingAppUpdates = "PendingAppUpdates"
+ static let jamfInfo = "Jamf"
+ static let fleetPolicies = "FleetPolicies"
+ static let fleetInfo = "Fleet"
}
-
+
enum CardTitle {
- static let storage = String(localized: "Card.StorageTitle", defaultValue: "Storage", comment: "Title for storage card")
- static let actions = String(localized: "Card.ActionsTitle", defaultValue: "Actions", comment: "Title for actions card")
- static let evergreen = String(localized: "Card.EvergreenTitle", defaultValue: "Evergreen", comment: "Title for evergreen card")
- static let deviceInfo = String(localized: "Card.DeviceInfoTitle", defaultValue: "Device Information", comment: "Title for device info card")
- static let deviceManagement = String(localized: "Card.DeviceManagementTitle", defaultValue: "Device Management", comment: "Title for device management card")
- static let appPatchProgress = String(localized: "Card.AppPatchProgressTitle", defaultValue: "Application Patching Progress", comment: "Title for app patch progress card")
- static let battery = String(localized: "Card.BatteryTitle", defaultValue: "Battery", comment: "Title for battery card")
- static let kerberosSSO = String(localized: "Card.KerberosSSOTitle", defaultValue: "Kerberos Single Sign On", comment: "Title for kerberos sso card")
- static let platformSSO = String(localized: "Card.PlatformSSOTitle", defaultValue: "Platform Single Sign On", comment: "Title for platform sso card")
- static let userInfo = String(localized: "Card.UserInfoTitle", defaultValue: "User Information", comment: "Title for user info card")
- static let pendingUpdates = String(localized: "Card.PendingUpdatesTitle", defaultValue: "Pending Updates", comment: "Title for pending updates card")
- static let installedApps = String(localized: "Card.InstalledAppsTitle", defaultValue: "Installed Applications", comment: "Title for installed apps card")
- static let privileges = String(localized: "Card.PrivilegesTitle", defaultValue: "Privileges", comment: "Title for privileges card")
+ static let storage = String(
+ localized: "Card.StorageTitle", defaultValue: "Storage",
+ comment: "Title for storage card")
+ static let actions = String(
+ localized: "Card.ActionsTitle", defaultValue: "Actions",
+ comment: "Title for actions card")
+ static let evergreen = String(
+ localized: "Card.EvergreenTitle", defaultValue: "Evergreen",
+ comment: "Title for evergreen card")
+ static let deviceInfo = String(
+ localized: "Card.DeviceInfoTitle", defaultValue: "Device Information",
+ comment: "Title for device info card")
+ static let deviceManagement = String(
+ localized: "Card.DeviceManagementTitle", defaultValue: "Device Management",
+ comment: "Title for device management card")
+ static let appPatchProgress = String(
+ localized: "Card.AppPatchProgressTitle", defaultValue: "Application Patching Progress",
+ comment: "Title for app patch progress card")
+ static let battery = String(
+ localized: "Card.BatteryTitle", defaultValue: "Battery",
+ comment: "Title for battery card")
+ static let kerberosSSO = String(
+ localized: "Card.KerberosSSOTitle", defaultValue: "Kerberos Single Sign On",
+ comment: "Title for kerberos sso card")
+ static let platformSSO = String(
+ localized: "Card.PlatformSSOTitle", defaultValue: "Platform Single Sign On",
+ comment: "Title for platform sso card")
+ static let userInfo = String(
+ localized: "Card.UserInfoTitle", defaultValue: "User Information",
+ comment: "Title for user info card")
+ static let pendingUpdates = String(
+ localized: "Card.PendingUpdatesTitle", defaultValue: "Pending Updates",
+ comment: "Title for pending updates card")
+ static let installedApps = String(
+ localized: "Card.InstalledAppsTitle", defaultValue: "Installed Applications",
+ comment: "Title for installed apps card")
+ static let privileges = String(
+ localized: "Card.PrivilegesTitle", defaultValue: "Privileges",
+ comment: "Title for privileges card")
+ static let jamfInfo = "Jamf"
+ static let fleetInfo = "Fleet"
+ static let fleetPolicies = String(
+ localized: "Card.FleetPoliciesTitle", defaultValue: "Device Compliance",
+ comment: "Title for the card listing failing Fleet policies")
}
-
+
enum RebootModal {
- static let title = String(localized: "Modal.RebootTitle", defaultValue: "Reboot Scheduled", comment: "Title for reboot modal")
+ static let title = String(
+ localized: "Modal.RebootTitle", defaultValue: "Reboot Scheduled",
+ comment: "Title for reboot modal")
static let countdown = 60
- static let message = String(localized: "Modal.RebootMessage", defaultValue: "Your system will reboot soon.", comment: "Message for reboot modal")
+ static let message = String(
+ localized: "Modal.RebootMessage", defaultValue: "Your system will reboot soon.",
+ comment: "Message for reboot modal")
}
-
+
enum Actions {
- static let reboot = String(localized: "Action.Reboot", defaultValue: "Reboot", comment: "Label for reboot action")
- static let changePassword = String(localized: "Action.ChangePassword", defaultValue: "Change Password", comment: "Label for change password action")
- static let gatherLogs = String(localized: "Action.GatherLogs", defaultValue: "Gather Logs", comment: "Label for gather logs action")
- static let restartIntuneAgent = String(localized: "Action.RestartIntuneAgent", defaultValue: "Restart Intune Agent", comment: "Label for restart Intune agent action")
- static let openManagementApp = String(localized: "Action.OpenManagementApp", defaultValue: "Open Management App", comment: "Label for open management app action")
- static let softwareUpdate = String(localized: "Action.SoftwareUpdate", defaultValue: "Software Update", comment: "Label for software update action")
- static let getSupport = String(localized: "Action.GetSupport", defaultValue: "Get Support", comment: "Label for get support action")
+ static let reboot = String(
+ localized: "Action.Reboot", defaultValue: "Reboot", comment: "Label for reboot action")
+ static let changePassword = String(
+ localized: "Action.ChangePassword", defaultValue: "Change Password",
+ comment: "Label for change password action")
+ static let gatherLogs = String(
+ localized: "Action.GatherLogs", defaultValue: "Gather Logs",
+ comment: "Label for gather logs action")
+ static let restartIntuneAgent = String(
+ localized: "Action.RestartIntuneAgent", defaultValue: "Restart Intune Agent",
+ comment: "Label for restart Intune agent action")
+ static let openManagementApp = String(
+ localized: "Action.OpenManagementApp", defaultValue: "Open Management App",
+ comment: "Label for open management app action")
+ static let softwareUpdate = String(
+ localized: "Action.SoftwareUpdate", defaultValue: "Software Update",
+ comment: "Label for software update action")
+ static let getSupport = String(
+ localized: "Action.GetSupport", defaultValue: "Get Support",
+ comment: "Label for get support action")
+ static let refetch = String(
+ localized: "Action.Refetch", defaultValue: "Re-check",
+ comment: "Button that asks Fleet to re-check this Mac")
+ static let refetching = String(
+ localized: "Action.Refetching", defaultValue: "Re-checking…",
+ comment: "Shown while Fleet re-checks this Mac")
+ static let refetchHelp = String(
+ localized: "Action.RefetchHelp",
+ defaultValue:
+ "Ask Fleet to update this Mac's details and re-run its compliance checks.",
+ comment: "Help text for the re-check button")
enum HideStrings {
static let changePassword = "ChangePassword"
@@ -157,46 +659,94 @@ enum Constants {
static let reboot = "Reboot"
}
}
-
+
enum ToastMessages {
enum SuccessMessages {
- static let gatherLogsSuccess = String(localized: "GatherLogs.Success", defaultValue: "Logs gathered successfully.", comment: "Message for successfully gathered logs")
+ static let gatherLogsSuccess = String(
+ localized: "GatherLogs.Success", defaultValue: "Logs gathered successfully.",
+ comment: "Message for successfully gathered logs")
}
enum FailureMessages {
- static let changePasswordSSOEFailure = String(localized: "ChangePasswordSSOE.Failure", defaultValue: "SSO Realm could not be fetched.", comment: "Failure message if REALM cannot be fetched")
+ static let changePasswordSSOEFailure = String(
+ localized: "ChangePasswordSSOE.Failure",
+ defaultValue: "SSO Realm could not be fetched.",
+ comment: "Failure message if REALM cannot be fetched")
}
enum InfoMessages {
- static let changePasswordSSOEInfo = String(localized: "ChangePasswordSSOE.Info", defaultValue: "Cannot reach %@. Ensure VPN or corporate network is connected.", comment: "Info message if REALM cannot be reached")
- static let gatherLogsInfo = String(localized: "GatherLogs.Info", defaultValue: "Gather logs was cancelled", comment: "Info message for gathering logs")
+ static let changePasswordSSOEInfo = String(
+ localized: "ChangePasswordSSOE.Info",
+ defaultValue: "Cannot reach %@. Ensure VPN or corporate network is connected.",
+ comment: "Info message if REALM cannot be reached")
+ static let gatherLogsInfo = String(
+ localized: "GatherLogs.Info", defaultValue: "Gather logs was cancelled",
+ comment: "Info message for gathering logs")
}
}
-
+
enum Navigation {
- static let home = String(localized: "Nav.Home", defaultValue: "Home", comment: "Label for home navigation")
- static let knowledgeBase = String(localized: "Nav.KnowledgeBase", defaultValue: "Knowledge Base", comment: "Label for knowledge base navigation")
- static let identity = String(localized: "Nav.Identity", defaultValue: "Identity", comment: "Label for identity navigation")
- static let apps = String(localized: "Nav.Apps", defaultValue: "Apps", comment: "Label for apps navigation")
- static let selfService = String(localized: "Nav.SelfService", defaultValue: "Self Service", comment: "Label for self service navigation")
+ static let home = String(
+ localized: "Nav.Home", defaultValue: "Home", comment: "Label for home navigation")
+ static let knowledgeBase = String(
+ localized: "Nav.KnowledgeBase", defaultValue: "Knowledge Base",
+ comment: "Label for knowledge base navigation")
+ static let identity = String(
+ localized: "Nav.Identity", defaultValue: "Identity",
+ comment: "Label for identity navigation")
+ static let apps = String(
+ localized: "Nav.Apps", defaultValue: "Apps", comment: "Label for apps navigation")
+ static let selfService = String(
+ localized: "Nav.SelfService", defaultValue: "Self Service",
+ comment: "Label for self service navigation")
+ static let companyPortal = String(
+ localized: "Nav.CompanyPortal", defaultValue: "Company Portal",
+ comment: "Label for company portal navigation")
+ static let compliance = String(
+ localized: "Nav.Compliance", defaultValue: "Compliance",
+ comment: "Label for compliance navigation")
}
-
+
enum DeviceInfo {
enum Labels {
- static let hostName = String(localized: "DeviceInfo.HostName", defaultValue: "Host Name:", comment: "Label for host name")
- static let model = String(localized: "DeviceInfo.Model", defaultValue: "Model:", comment: "Label for model")
- static let cpuType = String(localized: "DeviceInfo.CpuType", defaultValue: "Processor:", comment: "Label for CPU type")
- static let ram = String(localized: "DeviceInfo.Ram", defaultValue: "Memory:", comment: "Label for RAM")
- static let osVersion = String(localized: "DeviceInfo.OsVersion", defaultValue: "OS Version:", comment: "Label for OS version")
- static let osBuild = String(localized: "DeviceInfo.OsBuild", defaultValue: "OS Build:", comment: "Label for OS build")
- static let lastRestart = String(localized: "DeviceInfo.LastRestart", defaultValue: "Last Restart:", comment: "Label for last restart")
- static let ipAddress = String(localized: "DeviceInfo.IpAddress", defaultValue: "IP Address:", comment: "Label for IP address")
- static let serialNumber = String(localized: "DeviceInfo.SerialNumber", defaultValue: "Serial Number:", comment: "Label for serial number")
+ static let hostName = String(
+ localized: "DeviceInfo.HostName", defaultValue: "Host Name:",
+ comment: "Label for host name")
+ static let model = String(
+ localized: "DeviceInfo.Model", defaultValue: "Model:", comment: "Label for model")
+ static let cpuType = String(
+ localized: "DeviceInfo.CpuType", defaultValue: "Processor:",
+ comment: "Label for CPU type")
+ static let ram = String(
+ localized: "DeviceInfo.Ram", defaultValue: "Memory:", comment: "Label for RAM")
+ static let osVersion = String(
+ localized: "DeviceInfo.OsVersion", defaultValue: "OS Version:",
+ comment: "Label for OS version")
+ static let osBuild = String(
+ localized: "DeviceInfo.OsBuild", defaultValue: "OS Build:",
+ comment: "Label for OS build")
+ static let lastRestart = String(
+ localized: "DeviceInfo.LastRestart", defaultValue: "Last Restart:",
+ comment: "Label for last restart")
+ static let ipAddress = String(
+ localized: "DeviceInfo.IpAddress", defaultValue: "IP Address:",
+ comment: "Label for IP address")
+ static let serialNumber = String(
+ localized: "DeviceInfo.SerialNumber", defaultValue: "Serial Number:",
+ comment: "Label for serial number")
+ static let ssid = String(
+ localized: "DeviceInfo.SSID", defaultValue: "SSID:", comment: "Label for SSID")
}
enum Categories {
- static let hardwareSpecs = String(localized: "DeviceInfo.HardwareSpecs", defaultValue: "Hardware Specifications", comment: "Category for hardware specs")
- static let networkInfo = String(localized: "DeviceInfo.NetworkInfo", defaultValue: "Network Information", comment: "Category for network info")
- static let systemInfo = String(localized: "DeviceInfo.SystemInfo", defaultValue: "System Information", comment: "Category for system info")
+ static let hardwareSpecs = String(
+ localized: "DeviceInfo.HardwareSpecs", defaultValue: "Hardware Specifications",
+ comment: "Category for hardware specs")
+ static let networkInfo = String(
+ localized: "DeviceInfo.NetworkInfo", defaultValue: "Network Information",
+ comment: "Category for network info")
+ static let systemInfo = String(
+ localized: "DeviceInfo.SystemInfo", defaultValue: "System Information",
+ comment: "Category for system info")
}
-
+
enum Keys {
static let hostName = "HostName"
static let processor = "Processor"
@@ -207,17 +757,93 @@ enum Constants {
static let ipAddress = "IPAddress"
static let model = "Model"
static let serialNumber = "SerialNumber"
+ static let ssid = "SSID"
+ static let lastRestartDays = "LastRestartDays"
+ }
+ }
+
+ enum JamfInfo {
+ enum Labels {
+ static let lastCheckin = String(
+ localized: "JamfInfo.LastCheckin", defaultValue: "Check-In:",
+ comment: "Label for the last check-in date")
+ static let lastInventory = String(
+ localized: "JamfInfo.LastInventory", defaultValue: "Inventory:",
+ comment: "Label for the last update date")
+ static let url = String(
+ localized: "JamfInfo.URL", defaultValue: "URL:", comment: "Label for the URL")
+ static let id = String(
+ localized: "JamfInfo.ID", defaultValue: "ID:", comment: "Label for the ID")
+ }
+
+ enum Keys {
+ static let lastCheckin = "JamfLastCheckin"
+ static let lastInventory = "JamfLastInventory"
+ static let url = "JamfUrl"
+ static let id = "JamfId"
+ }
+ }
+
+ enum FleetInfo {
+ enum Labels {
+ static let id = String(
+ localized: "FleetInfo.ID", defaultValue: "Host ID:",
+ comment: "Label for the Fleet host ID")
+ static let team = String(
+ localized: "FleetInfo.Team", defaultValue: "Fleet:",
+ comment: "Label for the fleet name")
+ static let lastSeen = String(
+ localized: "FleetInfo.LastSeen", defaultValue: "Check-In:",
+ comment: "Label for when Fleet last heard from this Mac")
+ static let lastInventory = String(
+ localized: "FleetInfo.LastInventory", defaultValue: "Inventory:",
+ comment: "Label for when Fleet last updated this Mac's details")
+ static let url = String(
+ localized: "FleetInfo.URL", defaultValue: "URL:",
+ comment: "Label for the Fleet server")
}
+
+ enum Keys {
+ static let id = "FleetHostId"
+ static let team = "FleetTeam"
+ static let lastSeen = "FleetLastSeen"
+ static let lastInventory = "FleetLastInventory"
+ static let url = "FleetUrl"
+ }
+
+ static let noTeam = String(
+ localized: "FleetInfo.NoTeam", defaultValue: "No fleet",
+ comment: "Shown when the Mac isn't in a fleet")
+ static let never = String(
+ localized: "FleetInfo.Never", defaultValue: "Never",
+ comment: "Shown when Fleet hasn't recorded an event yet")
+ static let unknown = String(
+ localized: "FleetInfo.Unknown", defaultValue: "Unknown",
+ comment: "Shown before Fleet details load")
+ static let refetchFailed = String(
+ localized: "FleetInfo.RefetchFailed",
+ defaultValue: "Couldn't ask Fleet to re-check this Mac.",
+ comment: "Shown when a re-check request failed")
}
-
+
enum KerberosSSO {
enum Labels {
- static let exipiryDays = String(localized: "KerberosSSO.ExipiryDays", defaultValue: "AD Password Expiry:", comment: "Label for the number of days before the password expires")
- static let lastSSOPasswordChangeDays = String(localized: "KerberosSSO.LastSSOPasswordChangeDays", defaultValue: "Last AD Password Change:", comment: "Label for the last time the SSO password was changed")
- static let lastLocalPasswordChangeDays = String(localized: "KerberosSSO.LastLocalPasswordChangeDays", defaultValue: "Last Local Password Change:", comment: "Label for the last time the local password was changed")
- static let kerberosSSOUsername = String(localized: "KerberosSSO.Username", defaultValue: "Username:", comment: "Label for the username")
+ static let exipiryDays = String(
+ localized: "KerberosSSO.ExipiryDays", defaultValue: "AD Password Expiry:",
+ comment: "Label for the number of days before the password expires")
+ static let lastSSOPasswordChangeDays = String(
+ localized: "KerberosSSO.LastSSOPasswordChangeDays",
+ defaultValue: "Last AD Password Change:",
+ comment: "Label for the last time the SSO password was changed")
+ static let lastLocalPasswordChangeDays = String(
+ localized: "KerberosSSO.LastLocalPasswordChangeDays",
+ defaultValue: "Last Local Password Change:",
+ comment: "Label for the last time the local password was changed")
+ static let kerberosSSOUsername = String(
+ localized: "KerberosSSO.Username", defaultValue: "Username:",
+ comment: "Label for the username")
}
-
+
enum Keys {
static let expiryDays = "ExpiryDays"
static let lastSSOPasswordChangeDays = "LastSSOPasswordChangeDays"
@@ -226,18 +852,35 @@ enum Constants {
static let realm = "Realm"
}
}
-
+
enum PlatformSSO {
enum Labels {
- static let loginFrequency = String(localized: "PlatformSSO.LoginFrequency", defaultValue: "Login Frequency:", comment: "Label for the login frequency")
- static let loginType = String(localized: "PlatformSSO.LoginType", defaultValue: "Login Type:", comment: "Label for the login type")
- static let newUserAuthorizationMode = String(localized: "PlatformSSO.NewUserAuthorizationMode", defaultValue: "New User Authorization Mode:", comment: "Label for the new user authorization mode")
- static let registrationCompleted = String(localized: "PlatformSSO.RegistrationCompleted", defaultValue: "Registration Completed:", comment: "Label for the registration completion status")
- static let sdkVersionString = String(localized: "PlatformSSO.SDKVersionString", defaultValue: "SDK Version:", comment: "Label for the SDK version string")
- static let sharedDeviceKeys = String(localized: "PlatformSSO.SharedDeviceKeys", defaultValue: "Shared Device Keys:", comment: "Label for the shared device keys")
- static let userAuthorizationMode = String(localized: "PlatformSSO.UserAuthorizationMode", defaultValue: "User Authorization Mode:", comment: "Label for the user authorization mode")
+ static let loginFrequency = String(
+ localized: "PlatformSSO.LoginFrequency", defaultValue: "Login Frequency:",
+ comment: "Label for the login frequency")
+ static let loginType = String(
+ localized: "PlatformSSO.LoginType", defaultValue: "Login Type:",
+ comment: "Label for the login type")
+ static let newUserAuthorizationMode = String(
+ localized: "PlatformSSO.NewUserAuthorizationMode",
+ defaultValue: "New User Authorization Mode:",
+ comment: "Label for the new user authorization mode")
+ static let registrationCompleted = String(
+ localized: "PlatformSSO.RegistrationCompleted",
+ defaultValue: "Registration Completed:",
+ comment: "Label for the registration completion status")
+ static let sdkVersionString = String(
+ localized: "PlatformSSO.SDKVersionString", defaultValue: "SDK Version:",
+ comment: "Label for the SDK version string")
+ static let sharedDeviceKeys = String(
+ localized: "PlatformSSO.SharedDeviceKeys", defaultValue: "Shared Device Keys:",
+ comment: "Label for the shared device keys")
+ static let userAuthorizationMode = String(
+ localized: "PlatformSSO.UserAuthorizationMode",
+ defaultValue: "User Authorization Mode:",
+ comment: "Label for the user authorization mode")
}
-
+
enum Keys {
static let loginFrequency = "LoginFrequency"
static let loginType = "LoginType"
@@ -248,19 +891,41 @@ enum Constants {
static let userAuthorizationMode = "UserAuthorizationMode"
}
}
-
+
enum Battery {
enum Labels {
- static let health = String(localized: "Battery.Health", defaultValue: "Health:", comment: "Label for the battery health")
- static let cycleCount = String(localized: "Battery.CycleCount", defaultValue: "Cycle Count:", comment: "Label for the battery cycle count")
- static let temperature = String(localized: "Battery.Temperature", defaultValue: "Temperature:", comment: "Label for the battery temperature")
- static let isCharging = String(localized: "Battery.IsCharging", defaultValue: "Is Charging:", comment: "Label for the battery charging status")
- static let timeToFull = String(localized: "Battery.TimeToFull", defaultValue: "Time to Full:", comment: "Label for the battery time to full")
- static let charging = String(localized: "Battery.Charging", defaultValue: "Charging", comment: "Label for the battery charging status")
- static let notCharging = String(localized: "Battery.NotCharging", defaultValue: "Not Charging", comment: "Label for the battery charging status")
- static let usage = String(localized: "Battery.Usage", defaultValue: "Usage:", comment: "Label for the battery usage")
+ static let health = String(
+ localized: "Battery.Health", defaultValue: "Health:",
+ comment: "Label for the battery health")
+ static let cycleCount = String(
+ localized: "Battery.CycleCount", defaultValue: "Cycle Count:",
+ comment: "Label for the battery cycle count")
+ static let temperature = String(
+ localized: "Battery.Temperature", defaultValue: "Temperature:",
+ comment: "Label for the battery temperature")
+ static let isCharging = String(
+ localized: "Battery.IsCharging", defaultValue: "Is Charging:",
+ comment: "Label for the battery charging status")
+ static let timeToFull = String(
+ localized: "Battery.TimeToFull", defaultValue: "Time to Full:",
+ comment: "Label for the battery time to full")
+ static let charging = String(
+ localized: "Battery.Charging", defaultValue: "Charging",
+ comment: "Label for the battery charging status")
+ static let notCharging = String(
+ localized: "Battery.NotCharging", defaultValue: "Not Charging",
+ comment: "Label for the battery charging status")
+ static let fullyCharged = String(
+ localized: "Battery.FullyCharged", defaultValue: "Fully Charged",
+ comment: "Time to full value when the battery is full on external power")
+ static let calculating = String(
+ localized: "Battery.Calculating", defaultValue: "Calculating…",
+ comment: "Time to full value while macOS is still estimating the charge time")
+ static let usage = String(
+ localized: "Battery.Usage", defaultValue: "Usage:",
+ comment: "Label for the battery usage")
}
-
+
enum Keys {
static let health = "Health"
static let cycleCount = "CycleCount"
@@ -269,40 +934,54 @@ enum Constants {
static let timeToFull = "TimeToFull"
}
}
-
+
enum MDM {
enum Labels {
- static let enrolled = String(localized: "MDM.Enrolled", defaultValue: "Enrolled:", comment: "Label for the MDM enrollment status")
- static let enrolledDate = String(localized: "MDM.EnrolledDate", defaultValue: "Enrolled Date:", comment: "Label for the MDM enrollment date")
+ static let enrolled = String(
+ localized: "MDM.Enrolled", defaultValue: "Enrolled:",
+ comment: "Label for the MDM enrollment status")
+ static let enrolledDate = String(
+ localized: "MDM.EnrolledDate", defaultValue: "Enrolled Date:",
+ comment: "Label for the MDM enrollment date")
}
-
+
enum Keys {
static let enrolled = "Enrolled"
static let enrolledDate = "EnrolledDate"
}
}
-
+
enum Storage {
enum Labels {
- static let name = String(localized: "Storage.Name", defaultValue: "Name:", comment: "Label for the storage name")
+ static let name = String(
+ localized: "Storage.Name", defaultValue: "Name:",
+ comment: "Label for the storage name")
}
-
+
enum Keys {
static let name = "StorageName"
static let fileVault = "FileVault"
static let usage = "Usage"
}
}
-
+
enum UserInfo {
enum Labels {
- static let username = String(localized: "UserInfo.Username", defaultValue: "Username:", comment: "Label for the username")
- static let name = String(localized: "UserInfo.Name", defaultValue: "Name:", comment: "Label for the name")
- static let homeDir = String(localized: "UserInfo.HomeDir", defaultValue: "Home Directory:", comment: "Label for the home directory")
- static let shell = String(localized: "UserInfo.Shell", defaultValue: "Shell:", comment: "Label for the shell")
- static let isAdmin = String(localized: "UserInfo.IsAdmin", defaultValue: "Is Admin:", comment: "Label for the is admin status")
+ static let username = String(
+ localized: "UserInfo.Username", defaultValue: "Username:",
+ comment: "Label for the username")
+ static let name = String(
+ localized: "UserInfo.Name", defaultValue: "Name:", comment: "Label for the name")
+ static let homeDir = String(
+ localized: "UserInfo.HomeDir", defaultValue: "Home Directory:",
+ comment: "Label for the home directory")
+ static let shell = String(
+ localized: "UserInfo.Shell", defaultValue: "Shell:", comment: "Label for the shell")
+ static let isAdmin = String(
+ localized: "UserInfo.IsAdmin", defaultValue: "Is Admin:",
+ comment: "Label for the is admin status")
}
-
+
enum Keys {
static let username = "Login"
static let name = "Name"
@@ -311,10 +990,16 @@ enum Constants {
static let isAdmin = "IsAdmin"
}
}
-
- enum TabelHeaders {
- static let name = String(localized: "TabelHeaders.Name", defaultValue: "Name", comment: "Header for the name column")
- static let version = String(localized: "TabelHeaders.Version", defaultValue: "Version", comment: "Header for the version column")
- static let action = String(localized: "TabelHeaders.Action", defaultValue: "Action", comment: "Header for the action column")
+
+ enum TableHeaders {
+ static let name = String(
+ localized: "TableHeaders.Name", defaultValue: "Name",
+ comment: "Header for the name column")
+ static let version = String(
+ localized: "TableHeaders.Version", defaultValue: "Version",
+ comment: "Header for the version column")
+ static let action = String(
+ localized: "TableHeaders.Action", defaultValue: "Action",
+ comment: "Header for the action column")
}
}
diff --git a/SupportCompanion/ContentView.swift b/SupportCompanion/ContentView.swift
deleted file mode 100644
index 1ec24e3..0000000
--- a/SupportCompanion/ContentView.swift
+++ /dev/null
@@ -1,254 +0,0 @@
-//
-// ContentView.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-11.
-//
-
-import SwiftUI
-import Combine
-
-struct ContentView: View {
- @State private var selectedItem: SidebarItem?
- @Namespace private var animationNamespace
- @EnvironmentObject var preferences: Preferences
- @EnvironmentObject var appState: AppStateManager
- @StateObject private var webViewStateManager = WebViewStateManager()
- @State private var brandLogo: Image? = nil
- @State private var showLogo: Bool = false
- @State private var isShowingPopup = false
- @State private var modalButtonHovered: Bool = false
- @Environment(\.colorScheme) var colorScheme
-
- var body: some View {
- let sidebarItems = generateSidebarItems(preferences: appState.preferences, stateManager: webViewStateManager)
-
- NavigationSplitView {
- VStack(spacing: 10) {
- Spacer() // Push content to the center dynamically
-
- // Logo Section
- if showLogo, let logo = brandLogo {
- logo
- .resizable()
- .interpolation(.high)
- .antialiased(true)
- .scaledToFit()
- .frame(maxWidth: 230)
- .drawingGroup()
- .fixedSize(horizontal: false, vertical: true)
- .padding(.top, 20) // Minimal padding
- .padding(.horizontal, 20)
- }
-
- // Title Section
- if !appState.preferences.brandName.isEmpty {
- Text(appState.preferences.brandName)
- .font(.title)
- .multilineTextAlignment(.center)
- .padding(.top, 20) // Bring the title closer to the logo
- }
-
- Spacer() // Push content to the center dynamically
-
- // Sidebar List
- List(sidebarItems, selection: $selectedItem) { item in
- sidebarItem(for: item)
- }
- .listStyle(SidebarListStyle())
- .onAppear {
- loadLogoForCurrentColorScheme()
- if selectedItem == nil {
- selectedItem = sidebarItems.first
- }
- }
- .onChange(of: colorScheme) { _, _ in
- loadLogoForCurrentColorScheme()
- }
- .onChange(of: appState.preferences.brandLogo) { _, _ in
- loadLogoForCurrentColorScheme()
- }
- .onChange(of: appState.preferences.brandLogoLight) { _, _ in
- loadLogoForCurrentColorScheme()
- }
- .onReceive(NotificationCenter.default.publisher(for: .handleIncomingURL)) { notification in
- if let url = notification.object as? URL {
- handleIncomingURL(url, items: sidebarItems)
- }
- }
- .background(Color.clear)
- }
- .navigationSplitViewColumnWidth(
- min: 280, ideal: 280, max: 320)
- .frame(maxHeight: .infinity, alignment: .top)
- .background(Color.clear)
- } detail: {
- Group{
- if let selectedItem = selectedItem {
- selectedItem.destination
- .id(selectedItem.id)
- //.ignoresSafeArea(edges: .all)
- } else {
- Text("Select an option") // Placeholder if nothing is selected
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.gray.opacity(0.1))
- }
- }
- .toolbar {
- ToolbarItem(placement: .automatic) {
- ToolbarSupportButton(isShowingPopup: $isShowingPopup)
- }
-
- if #available(macOS 26.0, *) {
- ToolbarSpacer(.fixed)
- }
-
- ToolbarItem(placement: .automatic) {
- ToolbarDarkModeToggleView()
- }
- }
- }
- .sheet(isPresented: $isShowingPopup) {
- // The popup content
- PopupModal(isShowing: $isShowingPopup)
- }
- // Set the background color based on the color scheme with opacity
- .background(colorScheme == .dark ? Color.black.opacity(0.4) : Color.white.opacity(0.4))
- .background(.ultraThinMaterial)
- }
-
-
- private func handleIncomingURL(_ url: URL, items: [SidebarItem]) {
- guard url.scheme == "supportcompanion" else { return }
-
- switch url.host?.lowercased() {
- case "home":
- selectedItem = items.first(where: { $0.id == Constants.Navigation.home })
- case "identity":
- selectedItem = items.first(where: { $0.id == Constants.Navigation.identity })
- case "apps":
- selectedItem = items.first(where: { $0.id == Constants.Navigation.apps })
- case "selfservice":
- selectedItem = items.first(where: { $0.id == Constants.Navigation.selfService })
- case "companyportal":
- selectedItem = items.first(where: { $0.id == "Company Portal" })
- case "knowledgebase":
- selectedItem = items.first(where: { $0.id == Constants.Navigation.knowledgeBase })
- case "markdown":
- selectedItem = items.first(where: { $0.id == appState.preferences.markdownMenuLabel })
- default:
- selectedItem = items.first(where: { $0.id == Constants.Navigation.home })
- }
- }
-
- private func loadLogoForCurrentColorScheme() {
- let base64Logo = colorScheme == .dark ? appState.preferences.brandLogo : appState.preferences.brandLogoLight.isEmpty ? appState.preferences.brandLogo : appState.preferences.brandLogoLight
- showLogo = loadLogo(base64Logo: base64Logo)
- if showLogo {
- brandLogo = base64ToImage(base64Logo)
- }
- }
-
- struct ToolbarSupportButton: View {
- @EnvironmentObject var appState: AppStateManager
- @Binding var isShowingPopup: Bool
-
- var body: some View {
- if !appState.preferences.supportEmail.isEmpty && !appState.preferences.supportPhone.isEmpty {
- Button {
- isShowingPopup = true
- } label: {
- Label("Support Information", systemImage: "phone")
- }
- }
- }
- }
-
- struct ToolbarDarkModeToggleView: View {
- var body: some View {
- DarkLightModeToggle()
- .padding(.trailing, 5)
- .padding(.leading, 5)
- }
- }
-
-
- @ViewBuilder
- private func sidebarItem(for item: SidebarItem) -> some View {
- HStack {
- Image(systemName: item.systemImage)
- .resizable()
- .scaledToFit()
- .frame(width: 20, height: 20)
- Text(item.label)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- .padding()
- .background(
- Group {
- if selectedItem == item {
- Capsule()
- .fill(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor))
- .matchedGeometryEffect(id: "sidebar-highlight", in: animationNamespace)
- } else {
- Capsule()
- .fill(Color.clear)
- }
- }
- )
- .contentShape(Rectangle()) // Makes the entire area tappable
- .foregroundColor(selectedItem == item ? .white : .primary)
- .onTapGesture {
- withAnimation(.spring(response: 0.5, dampingFraction: 0.7)) {
- selectedItem = item
- }
- }
- .onHoverEffect(item)
- }
-}
-
-struct SidebarItemStyle: ViewModifier {
- //@State private var isHovered = false
- func body(content: Content) -> some View {
- content
- .font(.system(size: 16))
- .bold()
- .padding()
- .frame(maxWidth: .infinity, alignment: .leading)
- .toolbar(removing: .sidebarToggle)
- }
-}
-
-struct ContentView_Previews: PreviewProvider {
- static var previews: some View {
- ContentView()
- .environmentObject(Preferences())
- .environmentObject(DeviceInfoManager.shared)
- .environmentObject(StorageInfoManager.shared)
- .environmentObject(MdmInfoManager.shared)
- .environmentObject(BatteryInfoManager.shared)
- .frame(width: 1500, height: 900)
- }
-}
-
-private extension View {
- func onHoverEffect(_ item: SidebarItem) -> some View {
- modifier(HoverEffectModifier(item: item))
- }
-}
-
-struct HoverEffectModifier: ViewModifier {
- @State private var isHovered = false
- let item: SidebarItem
-
- func body(content: Content) -> some View {
- content
- .background(
- Capsule()
- .fill(isHovered ? Color.black.opacity(0.2) : Color.clear)
- )
- .onHover { hovering in
- isHovered = hovering
- }
- }
-}
diff --git a/SupportCompanion/Controllers/TransparentWindowController.swift b/SupportCompanion/Controllers/TransparentWindowController.swift
index f925056..5de64a5 100644
--- a/SupportCompanion/Controllers/TransparentWindowController.swift
+++ b/SupportCompanion/Controllers/TransparentWindowController.swift
@@ -1,16 +1,15 @@
import SwiftUI
-import Combine
import AppKit
class TransparentWindowController: NSWindowController {
private var appState: AppStateManager
- private var cancellables = Set()
+ private var positionObservation: ObservationToken?
init(appState: AppStateManager) {
self.appState = appState
let initialSize = NSSize(width: 400, height: 500)
let position = DesktopInfoPositionHelper.calculatePosition(
- for: appState.preferences.desktopInfoWindowPosition,
+ for: appState.preferences.desktopInfo.desktopInfoWindowPosition,
windowSize: initialSize
)
@@ -32,7 +31,7 @@ class TransparentWindowController: NSWindowController {
// Content view setup
let contentView = TransparentView()
- .environmentObject(appState)
+ .environment(appState)
.background(
GeometryReader { geometry in
Color.clear
@@ -48,13 +47,10 @@ class TransparentWindowController: NSWindowController {
window.contentView = NSHostingView(rootView: contentView)
self.updateWindowPosition()
- // Manually observe position changes
- appState.preferences.$currentWindowPosition
- .sink { [weak self] newPosition in
- guard let self = self else { return }
- self.updateWindowPosition()
- }
- .store(in: &cancellables)
+ // Move the window when the configured position changes
+ positionObservation = observeChanges(of: { appState.preferences.desktopInfo.desktopInfoWindowPosition }) { [weak self] _ in
+ self?.updateWindowPosition()
+ }
}
required init?(coder: NSCoder) {
@@ -66,11 +62,11 @@ class TransparentWindowController: NSWindowController {
DispatchQueue.main.async { [weak self] in
guard let window = self?.window else { return }
let position = DesktopInfoPositionHelper.calculatePosition(
- for: self?.appState.preferences.desktopInfoWindowPosition ?? "LowerRight",
+ for: self?.appState.preferences.desktopInfo.desktopInfoWindowPosition ?? "LowerRight",
windowSize: size
)
window.setContentSize(size)
- let yOffset: CGFloat = self?.appState.preferences.desktopInfoWindowPosition.contains("Lower") == true ? 20 : -20
+ let yOffset: CGFloat = self?.appState.preferences.desktopInfo.desktopInfoWindowPosition.contains("Lower") == true ? 20 : -20
window.setFrameOrigin(NSPoint(x: position.x, y: position.y + yOffset))
}
}
@@ -79,10 +75,10 @@ class TransparentWindowController: NSWindowController {
DispatchQueue.main.async { [weak self] in
guard let window = self?.window else { return }
let position = DesktopInfoPositionHelper.calculatePosition(
- for: self?.appState.preferences.desktopInfoWindowPosition ?? "LowerRight",
+ for: self?.appState.preferences.desktopInfo.desktopInfoWindowPosition ?? "LowerRight",
windowSize: window.frame.size
)
- let yOffset: CGFloat = self?.appState.preferences.desktopInfoWindowPosition.contains("Lower") == true ? 20 : -20
+ let yOffset: CGFloat = self?.appState.preferences.desktopInfo.desktopInfoWindowPosition.contains("Lower") == true ? 20 : -20
window.setFrameOrigin(NSPoint(x: position.x, y: position.y + yOffset))
}
}
diff --git a/SupportCompanion/ExecutionService.swift b/SupportCompanion/ExecutionService.swift
deleted file mode 100644
index a163be9..0000000
--- a/SupportCompanion/ExecutionService.swift
+++ /dev/null
@@ -1,103 +0,0 @@
-//
-// ExecutionService.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-12.
-//
-
-import Foundation
-
-// MARK: - ExecutionService
-
-/// Execute a script.
-enum ExecutionService {
-
- // MARK: Constants
- static let programURL = URL(fileURLWithPath: "/usr/bin/env")
-
- // MARK: Execute Script
- static func executeScript(at path: String) async throws -> String {
- try await HelperRemoteProvider.remote().executeScript(at: path)
- }
-
- // MARK: Execute Command
- /// Execute a command with arguments.
- static func executeCommandPrivileged(_ command: String, arguments: [String]) async throws -> String {
- try await HelperRemoteProvider.remote().executeCommand(command, with: arguments)
- }
-
- static func executeCommand(_ command: String, with arguments: [String] = []) async throws -> String {
- let process = Process()
- process.executableURL = programURL
- process.arguments = [command] + arguments
-
- let outputPipe = Pipe()
- let errorPipe = Pipe()
- process.standardOutput = outputPipe
- process.standardError = errorPipe
-
- try process.run()
- process.waitUntilExit()
-
- if process.terminationStatus != 0 {
- let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()
- let errorOutput = String(data: errorData, encoding: .utf8) ?? "Unknown error"
- throw NSError(
- domain: "ExecutionServiceError",
- code: Int(process.terminationStatus),
- userInfo: [
- NSLocalizedDescriptionKey: "Command '\(command)' failed with status \(process.terminationStatus): \(errorOutput)"
- ]
- )
- }
-
- let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
- return String(data: outputData, encoding: .utf8) ?? ""
- }
-
- static func executeCommandToFile(_ command: String, with arguments: [String] = []) async throws -> String {
- let tempURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("process_output.json")
- // Create the file if it doesn't exist
- FileManager.default.createFile(atPath: tempURL.path, contents: nil, attributes: nil)
-
- let process = Process()
- process.executableURL = URL(fileURLWithPath: command)
- process.arguments = arguments
- process.standardOutput = try FileHandle(forWritingTo: tempURL)
-
- try process.run()
- process.waitUntilExit()
-
- if process.terminationStatus != 0 {
- throw NSError(
- domain: "ExecutionServiceError",
- code: Int(process.terminationStatus),
- userInfo: [NSLocalizedDescriptionKey: "Command failed with status \(process.terminationStatus)"]
- )
- }
-
- let data = try Data(contentsOf: tempURL)
- return String(data: data, encoding: .utf8) ?? ""
- }
-
- static func executeShellCommand(_ rawCommand: String, isPrivileged: Bool? = false) async throws -> String {
- guard !rawCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
- Logger.shared.logDebug("Command must not be null or whitespace")
- throw NSError(domain: "ExecutionServiceError", code: 1, userInfo: [
- NSLocalizedDescriptionKey: "Command must not be null or whitespace"
- ])
- }
-
- // Escape single quotes within the command
- let escapedCommand = rawCommand.replacingOccurrences(of: "'", with: "'\\''")
-
- // Pass the escaped command directly to /bin/sh -c
- let arguments = ["-c", escapedCommand]
-
- if isPrivileged! {
- return try await executeCommandPrivileged("/bin/sh", arguments: arguments)
- }
- // Execute using the existing method
- return try await executeCommand("/bin/sh", with: arguments)
- }
-}
diff --git a/SupportCompanion/Extensions/Extensions.swift b/SupportCompanion/Extensions/Extensions.swift
index 41582ef..e684027 100644
--- a/SupportCompanion/Extensions/Extensions.swift
+++ b/SupportCompanion/Extensions/Extensions.swift
@@ -34,6 +34,20 @@ extension Double {
let divisor = pow(10.0, Double(places))
return (self * divisor).rounded() / divisor
}
+
+ /// Color for a storage usage percentage (0–100).
+ func storageColor(colorScheme: ColorScheme) -> Color {
+ if self < 50 { return .ScGreen }
+ if self < 80 { return colorScheme == .light ? .orangeLight : .orange }
+ return colorScheme == .light ? .redLight : .red
+ }
+
+ /// Color for a battery health percentage (0–100).
+ func batteryHealthColor(colorScheme: ColorScheme) -> Color {
+ if self <= 30 { return colorScheme == .light ? .redLight : .red }
+ if self < 80 { return colorScheme == .light ? .orangeLight : .orange }
+ return .ScGreen
+ }
}
extension View {
@@ -77,14 +91,18 @@ extension AppDelegate: NSWindowDelegate {
}
Logger.shared.logDebug("Main window is closing.")
AppStateManager.shared.windowIsVisible = false
+ // Explicitly remove the hosting controller before releasing the window controller.
+ // This ensures SwiftUI's onDisappear fires on all visible views (stopping battery/apps
+ // monitoring) and that @State-owned objects are torn down via proper view lifecycle
+ // rather than relying solely on the dealloc chain.
+ windowController?.window?.contentViewController = nil
windowController = nil
- AppStateManager.shared.jsonCardManager = nil
NSApp.setActivationPolicy(.accessory)
}
func windowDidBecomeKey(_ notification: Notification) {
AppStateManager.shared.windowIsVisible = true
- BadgeManager.shared.incrementBadgeCount(count: AppStateManager.shared.pendingUpdatesCount + AppStateManager.shared.systemUpdateCache.count)
+ BadgeManager.shared.incrementBadgeCount(count: AppStateManager.shared.attentionCount)
}
}
@@ -125,6 +143,28 @@ extension TimeInterval {
}
}
+extension Date {
+ /// Relative time for card rows, e.g. "5 minutes ago".
+ ///
+ /// Swedish and Norwegian build this with a leading preposition — "för 5 minuter sedan",
+ /// "for 5 minutter siden" — which is long for a row that sits next to a label. The
+ /// preposition is dropped for those two languages; the trailing "sedan"/"siden" still
+ /// carries the meaning, so the result reads correctly. Languages that carry the meaning
+ /// in the preposition instead, such as German ("vor 5 Minuten") and French
+ /// ("il y a 5 minutes"), are left alone, as is a named day like "i förrgår".
+ func relativeDescription() -> String {
+ let formatted = formatted(.relative(presentation: .named))
+ let preposition: String
+ switch Locale.current.language.languageCode?.identifier {
+ case "sv": preposition = "för "
+ case "nb", "nn", "no": preposition = "for "
+ default: return formatted
+ }
+ guard formatted.hasPrefix(preposition) else { return formatted }
+ return String(formatted.dropFirst(preposition.count))
+ }
+}
+
extension Color {
// Orange shades
static let orangeLight = Color(hue: 0.1, saturation: 0.9, brightness: 0.75) // Softer orange for light mode
@@ -140,7 +180,7 @@ extension Color {
}
extension Theme {
- static let sc = Theme.basic
+ @MainActor static var sc: Theme { Theme.basic
.codeBlock { configuration in
ScrollView(.horizontal) {
configuration.label
@@ -176,7 +216,7 @@ extension Theme {
.alternatingRows(
Color.primary.opacity(0.1),
Color.clear,
- header: (Color(NSColor(hex: AppStateManager.shared.preferences.accentColor ?? "") ?? NSColor.controlAccentColor))
+ header: (Color(NSColor(hex: AppStateManager.shared.preferences.branding.accentColor ?? "") ?? NSColor.controlAccentColor))
)
)
}
@@ -193,6 +233,7 @@ extension Theme {
.padding(.horizontal, 13)
.relativeLineSpacing(.em(0.25))
}
+ }
}
extension View {
diff --git a/SupportCompanion/Helpers/ActionHelpers.swift b/SupportCompanion/Helpers/ActionHelpers.swift
index deb3dfc..f194690 100644
--- a/SupportCompanion/Helpers/ActionHelpers.swift
+++ b/SupportCompanion/Helpers/ActionHelpers.swift
@@ -10,15 +10,13 @@ import AppKit
import Network
struct ActionHelpers {
-
- static private let tempArchivePath = Constants.Paths.tempArchivePath
-
+
enum OperationResult {
case success(String)
case failure(Error)
case info(String)
}
-
+
enum ConnectionError: LocalizedError {
case noInternetConnection
@@ -29,7 +27,7 @@ struct ActionHelpers {
}
}
}
-
+
enum SSOError: LocalizedError {
case invalidRealm
case commandFailed
@@ -43,230 +41,98 @@ struct ActionHelpers {
}
}
}
-
+
static func handleResult(
operationName: String,
result: OperationResult,
successMessage: String,
updateToast: @escaping (ToastConfig) -> Void
) {
- DispatchQueue.main.async {
- let toastConfig: ToastConfig
-
- switch result {
- case .success(let executionResult):
- if executionResult.contains("No matching processes") {
- toastConfig = .init(
- isShowing: true,
- type: .error(.red),
- title: operationName,
- subTitle: "\(operationName) was not running."
- )
- } else {
- toastConfig = .init(
- isShowing: true,
- type: .complete(.green),
- title: operationName,
- subTitle: successMessage
- )
- }
+ let toastConfig: ToastConfig
- case .failure(let error):
+ switch result {
+ case .success(let executionResult):
+ if executionResult.contains("No matching processes") {
toastConfig = .init(
isShowing: true,
type: .error(.red),
title: operationName,
- subTitle: error.localizedDescription
+ subTitle: "\(operationName) was not running."
)
-
- case .info(let info):
+ } else {
toastConfig = .init(
isShowing: true,
- type: .systemImage("info.circle.fill", .yellow),
+ type: .complete(.green),
title: operationName,
- subTitle: info
+ subTitle: successMessage
)
}
- updateToast(toastConfig)
- }
- }
+ case .failure(let error):
+ toastConfig = .init(
+ isShowing: true,
+ type: .error(.red),
+ title: operationName,
+ subTitle: error.localizedDescription
+ )
- static func openSystemUpdates() {
- Task{
- do {
- Logger.shared.logDebug("Opening system updates")
- try await _ = ExecutionService.executeCommand("open", with: [Constants.Panels.softwareUpdates])
- }
- catch {
- Logger.shared.logError("Failed to open system updates: \(error)")
- }
- }
- }
-
- static func openManagementApp(appURL: String) {
- Task {
- do {
- Logger.shared.logDebug("Opening Managed Software Center")
- try await _ = ExecutionService.executeCommand("open", with: [appURL])
- }
- catch {
- Logger.shared.logError("Failed to open Managed Software Center: \(error)")
- }
- }
- }
-
- static func openSupportPage(url: String) {
- Task {
- do {
- Logger.shared.logDebug("Opening support page \(url)")
- try await _ = ExecutionService.executeCommand("open", with: [url])
- }
- catch {
- Logger.shared.logError("Failed to open support page \(url): \(error)")
- }
+ case .info(let info):
+ toastConfig = .init(
+ isShowing: true,
+ type: .systemImage("info.circle.fill", .yellow),
+ title: operationName,
+ subTitle: info
+ )
}
- }
-
- static func reboot(completion: @escaping (OperationResult) -> Void) async {
- cancelShutdown()
-
- try? await Task.sleep(nanoseconds: 200_000_000) // 200ms delay
- // Show modal or trigger UI update
- DispatchQueue.main.async {
- Logger.shared.logDebug("Preparing to reboot")
- completion(.info("")) // Show modal or toast here
- }
-
- // Execute the reboot command directly
- do {
- _ = try await ExecutionService.executeCommandPrivileged("shutdown", arguments: ["-r", "+1"])
- Logger.shared.logDebug("Reboot command executed")
- } catch {
- if (error as NSError).domain == NSCocoaErrorDomain && (error as NSError).code == NSUserCancelledError {
- Logger.shared.logDebug("Reboot task was canceled")
- completion(.info("Reboot operation canceled by user"))
- } else {
- Logger.shared.logError("Failed to reboot: \(error)")
- completion(.failure(error))
- }
- }
+ updateToast(toastConfig)
}
-
- static func cancelShutdown() {
- do {
- Task {
- _ = try await ExecutionService.executeCommandPrivileged("killall", arguments: ["shutdown"])
- Logger.shared.logDebug("Cancel reboot command executed")
- }
- }
- }
-
- static func getSystemUpdateStatus(sendNotification: Bool = false) async -> Result<(Int, [String]), Error> {
+
+ @MainActor
+ static func getSystemUpdateStatus(sendNotification: Bool = false) async -> Result<(Int, [String], Bool), Error> {
let notificationService = NotificationService(appState: AppStateManager.shared)
let appState = AppStateManager.shared
-
+
do {
let executionResult = try await ExecutionService.executeCommand("/usr/sbin/softwareupdate", with: ["-l"])
let lines = executionResult.split(whereSeparator: \.isNewline)
-
+
var updateCount = 0
var updates: [String] = []
-
+ var isBackgroundSecurityImprovement: Bool = false
+
for line in lines {
if line.contains("*") {
updateCount += 1
updates.append(String(line))
}
+ if line.contains("Background Security Improvement") {
+ isBackgroundSecurityImprovement = true
+ }
}
-
+
if updateCount > 0 && sendNotification {
notificationService.sendNotification(
- message: appState.preferences.softwareUpdateNotificationMessage,
- buttonText: appState.preferences.softwareUpdateNotificationButtonText,
- command: appState.preferences.softwareUpdateNotificationCommand,
+ message: appState.preferences.notifications.softwareUpdateNotificationMessage,
+ buttonText: appState.preferences.notifications.softwareUpdateNotificationButtonText,
+ command: appState.preferences.notifications.softwareUpdateNotificationCommand,
notificationType: .softwareUpdate
)
}
-
- return .success((updateCount, updates))
+
+ return .success((updateCount, updates, isBackgroundSecurityImprovement))
} catch {
return .failure(error)
}
}
-
- static func gatherLogs(preferences: Preferences, completion: @escaping (OperationResult) -> Void) {
- let command = buildZipCommand(for: preferences.logFolders, excluding: preferences.excludedLogFolders)
- Logger.shared.logDebug("Gathering logs with command: \(command)")
- Task {
- do {
- _ = try await ExecutionService.executeCommand("/bin/sh", with: ["-c", command])
- Logger.shared.logDebug("Zip command executed successfully")
-
- guard let selectedURL = await promptSaveLocation() else {
- completion(.info(Constants.ToastMessages.InfoMessages.gatherLogsInfo))
- return
- }
-
- try saveArchive(to: selectedURL)
- completion(.success(selectedURL.path))
- } catch {
- Logger.shared.logError("Error gathering logs: \(error)")
- completion(.failure(error))
- }
- }
- }
-
- static private func buildZipCommand(for logFolders: [String], excluding excludedLogFolders: [String]) -> String {
- let fileManager = FileManager.default
- if fileManager.fileExists(atPath: tempArchivePath) {
- _ = try? fileManager.removeItem(at: URL(fileURLWithPath: tempArchivePath))
- }
- var command = "/usr/bin/zip -r \(tempArchivePath)"
- logFolders.forEach { command += " '\($0)'" }
- if !excludedLogFolders.isEmpty {
- command += " -x"
- excludedLogFolders.forEach { command += " '\($0)/*'" }
- }
- return command
- }
-
- static private func saveArchive(to location: URL) throws {
- let fileManager = FileManager.default
-
- if fileManager.fileExists(atPath: location.path) {
- try fileManager.removeItem(at: location)
- }
-
- try fileManager.copyItem(at: URL(fileURLWithPath: tempArchivePath), to: location)
- }
-
- @MainActor
- static private func promptSaveLocation() async -> URL? {
- Logger.shared.logDebug("Prompting user to save logs")
- let savePanel = NSSavePanel()
- savePanel.title = Constants.Titles.saveLogs
- savePanel.nameFieldStringValue = "supportcompanion_logs.zip"
-
- let response = savePanel.runModal()
- return response == .OK ? savePanel.url : nil
- }
static func restartIntuneAgent(completion: @escaping (OperationResult) -> Void) {
Task {
do {
- let executionResult = try await ExecutionService.executeCommandPrivileged(
- "killall",
- arguments: ["IntuneMdmAgent"]
- )
- DispatchQueue.main.async {
- completion(.success(executionResult))
- }
+ let executionResult = try await ExecutionService.restartIntuneAgent()
+ completion(.success(executionResult))
} catch {
- DispatchQueue.main.async {
- completion(.failure(error))
- }
+ completion(.failure(error))
}
}
}
@@ -277,27 +143,28 @@ struct ActionHelpers {
return
}
- if preferences.changePasswordMode == "url" {
+ if await preferences.changePasswordMode == "url" {
await openURL(preferences.changePasswordUrl, completion: completion)
- } else if preferences.changePasswordMode == "SSOExtension" {
+ } else if await preferences.changePasswordMode == "SSOExtension" {
await handleSSOExtension(completion: completion)
} else {
- await openUserPanel()
+ openUserPanel()
}
}
- static func openUserPanel() {
- Task {
- do {
- Logger.shared.logDebug("Opening Users & Groups")
- try await _ = ExecutionService.executeCommand("open", with: [Constants.Panels.users])
- } catch {
- Logger.shared.logError("Failed to open Users & Groups: \(error)")
- }
- }
+ static func checkForInternetConnection() async -> Bool {
+ let monitor = NWPathMonitor()
+ let queue = DispatchQueue(label: "NetworkMonitor")
+
+ monitor.start(queue: queue)
+ try? await Task.sleep(nanoseconds: 500_000_000)
+ let isConnected = monitor.currentPath.status == .satisfied
+ monitor.cancel()
+
+ return isConnected
}
- static private func openURL(_ url: String, completion: @escaping (OperationResult) -> Void) async {
+ private static func openURL(_ url: String, completion: @escaping (OperationResult) -> Void) async {
do {
_ = try await ExecutionService.executeCommand("open", with: [url])
Logger.shared.logDebug("URL opened: \(url)")
@@ -306,14 +173,14 @@ struct ActionHelpers {
}
}
- static private func handleSSOExtension(completion: @escaping (OperationResult) -> Void) async {
+ private static func handleSSOExtension(completion: @escaping (OperationResult) -> Void) async {
do {
let realmInfo = try await ExecutionService.executeCommand("/usr/bin/app-sso", with: ["-l", "--json"])
guard let realmName = parseRealm(from: realmInfo) else {
throw SSOError.invalidRealm
}
- let reachable = try await ping(host: realmName)
+ let reachable = await ping(host: realmName)
if reachable {
_ = try await ExecutionService.executeCommand("/usr/bin/app-sso", with: ["-c", realmName])
Logger.shared.logDebug("Password change initiated for realm: \(realmName)")
@@ -326,43 +193,38 @@ struct ActionHelpers {
}
}
- static private func parseRealm(from json: String) -> String? {
+ private static func parseRealm(from json: String) -> String? {
guard let data = json.data(using: .utf8),
- let realms = try? JSONDecoder().decode([String].self, from: data) else {
+ let realms = try? JSONDecoder().decode([String].self, from: data) else {
return nil
}
return realms.first
}
-
- static func checkForInternetConnection() async -> Bool {
- let monitor = NWPathMonitor()
- let queue = DispatchQueue(label: "NetworkMonitor")
- var isConnected = false
- monitor.pathUpdateHandler = { path in
- isConnected = path.status == .satisfied
+ private static func ping(host: String) async -> Bool {
+ await withCheckedContinuation { continuation in
+ let connection = NWConnection(
+ host: NWEndpoint.Host(host),
+ port: 443,
+ using: .tcp
+ )
+ connection.stateUpdateHandler = { state in
+ switch state {
+ case .ready:
+ Logger.shared.logDebug("Ping successful to host: \(host)")
+ connection.cancel()
+ continuation.resume(returning: true)
+ case .failed(let error):
+ Logger.shared.logError("Ping failed to host: \(host) with error: \(error)")
+ connection.cancel()
+ continuation.resume(returning: false)
+ case .cancelled:
+ break
+ default:
+ break
+ }
+ }
+ connection.start(queue: .global())
}
-
- monitor.start(queue: queue)
- try? await Task.sleep(nanoseconds: 500_000_000) // Wait for 0.5 seconds
- monitor.cancel()
-
- return isConnected
- }
-
- static private func ping(host: String) async throws -> Bool {
- let process = Process()
- let pipe = Pipe()
-
- process.executableURL = URL(fileURLWithPath: "/sbin/ping")
- process.arguments = ["-c", "1", host] // Send 1 ping
-
- process.standardOutput = pipe
- process.standardError = pipe
-
- try process.run()
- process.waitUntilExit()
-
- return process.terminationStatus == 0
}
}
diff --git a/SupportCompanion/Helpers/BatteryHelpers.swift b/SupportCompanion/Helpers/BatteryHelpers.swift
index 334eff6..4c69a0d 100644
--- a/SupportCompanion/Helpers/BatteryHelpers.swift
+++ b/SupportCompanion/Helpers/BatteryHelpers.swift
@@ -15,7 +15,9 @@ func getBatteryDesignCapacity() -> Int? {
}
func getBatteryMaxCapacity() -> Int? {
- return getBatteryProperty(forKey: "AppleRawMaxCapacity") as? Int
+ // AppleRawMaxCapacity was removed in macOS 27; FullChargeCapacity is the closest match to
+ // the "Maximum Capacity" shown in System Settings
+ return (getBatteryProperty(forKey: "AppleRawMaxCapacity") ?? getBatteryProperty(forKey: "FullChargeCapacity")) as? Int
}
func getBatteryCycleCount() -> Int? {
@@ -31,22 +33,18 @@ func getBatteryHealthPercentage() -> Double? {
return nil
}
-/*func getBatteryTemperature() -> Double? {
- if let temperature = getBatteryProperty(forKey: "Temperature") as? Int {
- // Convert temperature from deciKelvins to Celsius
- return Double(temperature) / 10.0 - 273.15
- }
- return nil
-}*/
-
+/// Nil when the battery doesn't report a temperature (macOS 27 no longer exposes it).
func getBatteryTemperature() -> Double? {
- guard let temperature = getBatteryProperty(forKey: "Temperature") as? Int else {
+ let celsius: Double
+ if let temperature = getBatteryProperty(forKey: "Temperature") as? Int {
+ // Older macOS reports deciKelvins in the battery's IORegistry entry
+ celsius = Double(temperature) / 10.0 - 273.15
+ } else if let sensorTemperature = getBatterySensorTemperature() {
+ celsius = sensorTemperature
+ } else {
return nil
}
- // Convert temperature from deciKelvins to Celsius
- let celsius = Double(temperature) / 10.0 - 273.15
-
// Determine the preferred unit (Celsius or Fahrenheit)
let locale = Locale.current
let usesMetric = locale.measurementSystem == .metric
@@ -74,18 +72,78 @@ func isBatteryCharging() -> String {
}
func getBatteryTimeRemaining() -> String {
- let isCharging = isBatteryCharging()
- if isCharging != Constants.Battery.Labels.charging{
+ let onExternalPower = getBatteryProperty(forKey: "ExternalConnected") as? Bool ?? false
+ guard onExternalPower else {
return "N/A"
}
-
- if let timeToFull = getBatteryProperty(forKey: "TimeRemaining") as? Int {
- if timeToFull == 65535 {
- return "N/A"
+
+ if isBatteryCharging() != Constants.Battery.Labels.charging {
+ // On power but not charging: either full, or held below 100% by optimized charging or a charge limit
+ let currentCapacity = getBatteryProperty(forKey: "CurrentCapacity") as? Int ?? 0
+ let fullyCharged = getBatteryProperty(forKey: "FullyCharged") as? Bool ?? false
+ return fullyCharged || currentCapacity >= 100 ? Constants.Battery.Labels.fullyCharged : Constants.Battery.Labels.notCharging
+ }
+
+ // 65535 means macOS hasn't estimated the charge time yet
+ let estimates = ["AvgTimeToFull", "TimeRemaining"].compactMap { getBatteryProperty(forKey: $0) as? Int }
+ if let minutes = estimates.first(where: { $0 > 0 && $0 < 65535 }) {
+ return "\(minutes) \(Constants.General.minutes)"
+ }
+ return Constants.Battery.Labels.calculating
+}
+
+// MARK: - HID temperature sensors
+
+// macOS 27 no longer reports the battery temperature in the AppleSmartBattery IORegistry entry.
+// The battery's fuel gauge still exposes it as a HID temperature sensor ("gas gauge battery"),
+// read through the private IOHIDEventSystemClient API, as tools like Stats do. This is undocumented
+// and may change, so callers must handle nil.
+
+private typealias IOHIDEventSystemClientRef = OpaquePointer
+private typealias IOHIDServiceClientRef = OpaquePointer
+private typealias IOHIDEventRef = OpaquePointer
+
+@_silgen_name("IOHIDEventSystemClientCreate")
+private func IOHIDEventSystemClientCreate(_ allocator: CFAllocator?) -> IOHIDEventSystemClientRef?
+@_silgen_name("IOHIDEventSystemClientSetMatching")
+private func IOHIDEventSystemClientSetMatching(_ client: IOHIDEventSystemClientRef, _ matching: CFDictionary) -> Int32
+@_silgen_name("IOHIDEventSystemClientCopyServices")
+private func IOHIDEventSystemClientCopyServices(_ client: IOHIDEventSystemClientRef) -> Unmanaged?
+@_silgen_name("IOHIDServiceClientCopyProperty")
+private func IOHIDServiceClientCopyProperty(_ service: IOHIDServiceClientRef, _ key: CFString) -> Unmanaged?
+@_silgen_name("IOHIDServiceClientCopyEvent")
+private func IOHIDServiceClientCopyEvent(_ service: IOHIDServiceClientRef, _ type: Int64, _ options: Int32, _ timestamp: Int64) -> IOHIDEventRef?
+@_silgen_name("IOHIDEventGetFloatValue")
+private func IOHIDEventGetFloatValue(_ event: IOHIDEventRef, _ field: Int32) -> Double
+
+/// Highest reading from the battery's "gas gauge battery" HID sensors in Celsius, or nil if there are none.
+private func getBatterySensorTemperature() -> Double? {
+ let temperatureEventType: Int64 = 15 // kIOHIDEventTypeTemperature
+ let temperatureField = Int32(temperatureEventType << 16) // kIOHIDEventFieldTemperatureLevel
+
+ guard let client = IOHIDEventSystemClientCreate(kCFAllocatorDefault) else {
+ return nil
+ }
+ // Vendor-defined usage page and usage for temperature sensors
+ _ = IOHIDEventSystemClientSetMatching(client, ["PrimaryUsagePage": 0xff00, "PrimaryUsage": 5] as CFDictionary)
+
+ guard let services = IOHIDEventSystemClientCopyServices(client)?.takeRetainedValue() as? [AnyObject] else {
+ return nil
+ }
+
+ let readings: [Double] = services.compactMap { serviceObject in
+ let service = OpaquePointer(Unmanaged.passUnretained(serviceObject).toOpaque())
+ guard let product = IOHIDServiceClientCopyProperty(service, "Product" as CFString)?.takeRetainedValue() as? String,
+ product.localizedCaseInsensitiveContains("gas gauge battery"),
+ let event = IOHIDServiceClientCopyEvent(service, temperatureEventType, 0, 0) else {
+ return nil
}
- return "\(timeToFull) \(Constants.General.minutes)"
+ let value = IOHIDEventGetFloatValue(event, temperatureField)
+ // Some sensors report garbage (e.g. -9200); only keep plausible battery temperatures
+ return (-20...120).contains(value) ? value : nil
}
- return "Unknown"
+
+ return readings.max()
}
// MARK: - Helper Methods
@@ -99,11 +157,15 @@ private func getBatteryProperty(forKey key: String) -> Any? {
defer { IOObjectRelease(service) }
- if let properties = getIOProperties(service: service) {
- return properties[key]
+ guard let properties = getIOProperties(service: service) else {
+ return nil
}
- return nil
+ // macOS 27 moved most battery values (DesignCapacity, FullChargeCapacity, …) into BatteryData
+ if let value = properties[key] {
+ return value
+ }
+ return (properties["BatteryData"] as? [String: Any])?[key]
}
private func getIOProperties(service: io_service_t) -> [String: Any]? {
@@ -114,44 +176,3 @@ private func getIOProperties(service: io_service_t) -> [String: Any]? {
}
return props
}
-
-
-class BatteryMonitor: ObservableObject {
- @Published var batteryTemperature: Double? = nil
- @Published var isCharging: String? = nil
- @Published var cycleCount: Int? = nil
-
- private var monitorTask: Task? // The background task
-
- /// Starts monitoring battery properties.
- func startMonitoring() {
- stopMonitoring() // Ensure no duplicate tasks
-
- monitorTask = Task {
- while !Task.isCancelled {
- do {
- // Fetch battery data
- let temperature = getBatteryTemperature()
- let chargingStatus = isBatteryCharging()
- let cycleCount = getBatteryCycleCount()
-
- // Update the published properties on the main thread
- await MainActor.run {
- self.batteryTemperature = temperature
- self.isCharging = chargingStatus
- self.cycleCount = cycleCount
- }
- }
-
- // Wait for the specified interval before checking again
- try? await Task.sleep(nanoseconds: UInt64(60 * 1_000_000_000))
- }
- }
- }
-
- /// Stops the monitoring task.
- func stopMonitoring() {
- monitorTask?.cancel()
- monitorTask = nil
- }
-}
diff --git a/SupportCompanion/Helpers/DeviceInfoHelpers.swift b/SupportCompanion/Helpers/DeviceInfoHelpers.swift
index 6045e2b..e58bb14 100644
--- a/SupportCompanion/Helpers/DeviceInfoHelpers.swift
+++ b/SupportCompanion/Helpers/DeviceInfoHelpers.swift
@@ -6,8 +6,6 @@
//
import Foundation
-import IOKit
-import Network
func getHostName() -> String? {
let hostName = ProcessInfo.processInfo.hostName
@@ -44,9 +42,9 @@ func getOSBuild() -> String {
let osBuild = ProcessInfo.processInfo.operatingSystemVersionString
if let startIndex = osBuild.firstIndex(of: "("),
let endIndex = osBuild.firstIndex(of: ")") {
- osBuildString = String(osBuild[osBuild.index(after: startIndex).. 1 {
@@ -73,18 +71,12 @@ func getLastRebootDays() -> Int? {
var size = MemoryLayout.stride
let result = sysctl(&mib, 2, &bootTime, &size, nil, 0)
- guard result == 0 else {
- return nil
- }
+ guard result == 0 else { return nil }
let bootDate = Date(timeIntervalSince1970: TimeInterval(bootTime.tv_sec))
let currentDate = Date()
-
- // Calculate the difference in days
let calendar = Calendar.current
- let daysSinceReboot = calendar.dateComponents([.day], from: bootDate, to: currentDate).day
-
- return daysSinceReboot
+ return calendar.dateComponents([.day], from: bootDate, to: currentDate).day
}
func getLastRestartMinutes() -> Int? {
@@ -93,130 +85,29 @@ func getLastRestartMinutes() -> Int? {
var size = MemoryLayout.stride
let result = sysctl(&mib, 2, &bootTime, &size, nil, 0)
- guard result == 0 else {
- return nil
- }
+ guard result == 0 else { return nil }
let bootDate = Date(timeIntervalSince1970: TimeInterval(bootTime.tv_sec))
- let currentDate = Date()
-
- let elapsedMinutes = Int(currentDate.timeIntervalSince(bootDate) / 60)
+ let elapsedMinutes = Int(Date().timeIntervalSince(bootDate) / 60)
return elapsedMinutes
}
-func getModelName() -> String {
- return getPropertyValue(forKey: "product-name", service: "product") ?? "Unknown"
-}
-
-func getCPUName() -> String? {
- var size: Int = 0
- sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0)
- var cpuName = [CChar](repeating: 0, count: size)
- sysctlbyname("machdep.cpu.brand_string", &cpuName, &size, nil, 0)
- return String(cString: cpuName)
-}
-
-func getRAMSize() -> String {
- let byteCount = ProcessInfo.processInfo.physicalMemory
- let formatter = ByteCountFormatter()
- formatter.allowedUnits = .useGB
- formatter.countStyle = .memory
- return formatter.string(fromByteCount: Int64(byteCount))
-}
-
-func getSerialNumber() -> String {
- return getPropertyValue(forKey: "IOPlatformSerialNumber", service: "IOPlatformExpertDevice") ?? "Unknown"
-}
-
-func getAllIPAddresses() -> [String] {
- var ipAddresses: [String] = []
- var ifaddr: UnsafeMutablePointer?
-
- // Get the list of all interfaces
- if getifaddrs(&ifaddr) == 0 {
- var ptr = ifaddr
- while ptr != nil {
- defer { ptr = ptr?.pointee.ifa_next }
-
- let interface = ptr?.pointee
- let addrFamily = interface?.ifa_addr.pointee.sa_family
-
- // Check if the address is IPv4 (AF_INET)
- if addrFamily == UInt8(AF_INET) {
- if let currentInterfaceName = interface?.ifa_name {
- let name = String(cString: currentInterfaceName)
-
- // Include only Wi-Fi and Ethernet interfaces
- if name == "en0" || name.hasPrefix("en") { // Add other Ethernet interfaces if needed
- // Get the IPv4 address
- var addr = interface!.ifa_addr.pointee
- var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
- getnameinfo(&addr, socklen_t(interface!.ifa_addr.pointee.sa_len), &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST)
-
- let address = String(cString: hostname)
- ipAddresses.append("\(address)")
- }
- }
- }
- }
- freeifaddrs(ifaddr)
- }
- return ipAddresses
-}
-
-class IPAddressMonitor {
- private static let monitor = NWPathMonitor()
- private static let queue = DispatchQueue.global(qos: .background)
- private static var lastUpdateTime: Date?
-
- /// Starts monitoring for IP address changes and calls `onChange` with the updated IPs.
- static func startMonitoring(onChange: @escaping ([String]) -> Void) {
- monitor.pathUpdateHandler = { path in
- let now = Date()
- if let lastUpdate = lastUpdateTime, now.timeIntervalSince(lastUpdate) < 2.0 {
- // Skip updates within 2 seconds
- return
- }
- lastUpdateTime = now
-
- if path.status == .satisfied {
- let currentIPAddresses = getAllIPAddresses()
- DispatchQueue.main.async {
- onChange(currentIPAddresses)
- }
- } else {
- DispatchQueue.main.async {
- onChange(["No network connection"])
- }
- }
- }
- monitor.start(queue: queue)
- }
-
- static func stopMonitoring() {
- monitor.cancel()
- }
-}
-
func formattedRebootContent(value: Int) -> String {
var formattedLastRestart: String {
- if value >= 1440 { // 1440 minutes in a day
+ if value >= 1440 {
let days = value / 1440
- if days == 1 {
- return "\(days) \(Constants.General.dayAgo)"
- }
- return "\(days) \(Constants.General.daysAgo)"
- } else if value >= 60 { // More than an hour
+ return days == 1
+ ? "\(days) \(Constants.General.dayAgo)"
+ : "\(days) \(Constants.General.daysAgo)"
+ } else if value >= 60 {
let hours = value / 60
- if hours == 1 {
- return "\(hours) \(Constants.General.hour)"
- }
- return "\(hours) \(Constants.General.hours)"
- } else { // Less than an hour
- if value == 1 {
- return "\(value) \(Constants.General.minute)"
- }
- return "\(value) \(Constants.General.minutes)"
+ return hours == 1
+ ? "\(hours) \(Constants.General.hour)"
+ : "\(hours) \(Constants.General.hours)"
+ } else {
+ return value == 1
+ ? "\(value) \(Constants.General.minute)"
+ : "\(value) \(Constants.General.minutes)"
}
}
return formattedLastRestart
@@ -230,8 +121,6 @@ class LastRebootMonitor {
func startMonitoring(onUpdate: @escaping (Int) -> Void) {
self.updateHandler = onUpdate
-
- // Perform the reboot check
let lastRebootDays = getLastRestartMinutes()
DispatchQueue.main.async {
self.updateHandler?(lastRebootDays ?? 0)
diff --git a/SupportCompanion/Helpers/ElevationHelpers.swift b/SupportCompanion/Helpers/ElevationHelpers.swift
index c2bf0e3..5c0ad25 100644
--- a/SupportCompanion/Helpers/ElevationHelpers.swift
+++ b/SupportCompanion/Helpers/ElevationHelpers.swift
@@ -51,6 +51,7 @@ func authenticateWithPassword(completion: @escaping (Bool) -> Void, reason: Stri
}
}
+@MainActor
func saveReasonToDisk(reason: String) {
let fileManager = FileManager.default
let appState = AppStateManager.shared
@@ -91,7 +92,7 @@ func saveReasonToDisk(reason: String) {
"user": NSUserName(),
"host": Host.current().localizedName ?? "Unknown",
"serial": appState.deviceInfoManager.deviceInfo?.serialNumber ?? "Unknown",
- "severity": appState.preferences.elevationSeverity
+ "severity": appState.preferences.elevation.elevationSeverity
]
var existingEntries: [[String: Any]] = []
@@ -123,54 +124,54 @@ func saveReasonToDisk(reason: String) {
}
}
+@MainActor
func sendReasonToWebhook(reason: String) {
let dateFormatter = ISO8601DateFormatter()
let appState = AppStateManager.shared
- // Define the webhook URL
- let webhookURL = URL(string: appState.preferences.elevationWebhookURL)!
-
- // Create a dictionary with the reason
+ guard let webhookURL = URL(string: appState.preferences.elevation.elevationWebhookURL),
+ !appState.preferences.elevation.elevationWebhookURL.isEmpty else {
+ Logger.shared.logError("Invalid or empty webhook URL.")
+ saveReasonToDisk(reason: reason)
+ return
+ }
+
let payload: [String: Any] = [
- "reason": reason,
+ "reason": reason,
"date": dateFormatter.string(from: Date()),
"user": NSUserName(),
"host": Host.current().localizedName ?? "Unknown",
"serial": appState.deviceInfoManager.deviceInfo?.serialNumber ?? "Unknown",
- "severity":appState.preferences.elevationSeverity
+ "severity": appState.preferences.elevation.elevationSeverity
]
-
- // Serialize the dictionary to JSON
+
guard let jsonData = try? JSONSerialization.data(withJSONObject: payload) else {
- print("Failed to serialize JSON.")
+ Logger.shared.logError("Failed to serialize elevation webhook payload.")
+ saveReasonToDisk(reason: reason)
return
}
-
- // Create a POST request
+
var request = URLRequest(url: webhookURL)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = jsonData
-
- // Create a URLSession task
+
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
- saveReasonToDisk(reason: reason)
+ Task { @MainActor in saveReasonToDisk(reason: reason) }
Logger.shared.logError("Failed to send reason to webhook: \(error.localizedDescription)")
return
}
-
+
if let response = response as? HTTPURLResponse {
if response.statusCode == 200 || response.statusCode == 202 {
- print("Reason sent to webhook successfully.")
+ Logger.shared.logDebug("Reason sent to webhook successfully.")
} else {
- // Fallback to save to disk if webhook fails
- saveReasonToDisk(reason: reason)
+ Task { @MainActor in saveReasonToDisk(reason: reason) }
Logger.shared.logError("Failed to send reason to webhook. Status code: \(response.statusCode)")
}
}
}
-
- // Start the task
+
task.resume()
}
diff --git a/SupportCompanion/Helpers/FileWatcher.swift b/SupportCompanion/Helpers/FileWatcher.swift
index 1e4f763..006e722 100644
--- a/SupportCompanion/Helpers/FileWatcher.swift
+++ b/SupportCompanion/Helpers/FileWatcher.swift
@@ -1,30 +1,82 @@
import Foundation
+// Watches a file for changes. Handles editors that save atomically (write temp + rename)
+// by listening for rename/delete and reopening the file descriptor.
+
class FileWatcher {
private var source: DispatchSourceFileSystemObject?
- private let fileDescriptor: Int32
+ private var fileDescriptor: Int32 = -1
+ private let filePath: String
+ private let queue = DispatchQueue(label: "com.github.macadmins.SupportCompanion.FileWatcher")
+ private var pendingReload = false
init?(filePath: String, eventHandler: @escaping () -> Void) {
- let fileDescriptor = open(filePath, O_EVTONLY)
- guard fileDescriptor >= 0 else {
- Logger.shared.logError("Failed to open file: \(filePath)")
- return nil
+ self.filePath = filePath
+ guard reopen(eventHandler: eventHandler) else { return nil }
+ Logger.shared.logDebug("Initializing file watcher for \(filePath)")
+ }
+
+ // Recreate the dispatch source and ensure it is resumed.
+ private func reopen(eventHandler: @escaping () -> Void) -> Bool {
+ // Tear down old source if any
+ if let src = source {
+ src.cancel()
+ source = nil
}
- self.fileDescriptor = fileDescriptor
-
- let source = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fileDescriptor, eventMask: .write, queue: DispatchQueue.global())
- source.setEventHandler(handler: eventHandler)
- source.setCancelHandler {
- close(fileDescriptor)
+ if fileDescriptor >= 0 {
+ // fd will be closed by cancel handler; reset our tracking value here
+ fileDescriptor = -1
}
- Logger.shared.logDebug("Initializing file watcher for \(filePath)")
- source.resume()
- self.source = source
+
+ let fd = open(filePath, O_EVTONLY)
+ guard fd >= 0 else {
+ Logger.shared.logError("Failed to open file for watching: \(filePath)")
+ return false
+ }
+ fileDescriptor = fd
+
+ let src = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fd,
+ eventMask: [.write, .rename, .delete, .extend, .attrib],
+ queue: queue
+ )
+ src.setCancelHandler { [fd] in
+ close(fd)
+ }
+
+ // Set handler referencing self.source to always act on the current source
+ src.setEventHandler { [weak self] in
+ guard let self = self, let current = self.source else { return }
+ let flags = current.data
+
+ if flags.contains(.rename) || flags.contains(.delete) {
+ Logger.shared.logDebug("FileWatcher: rename/delete detected for \(self.filePath), reopening FD")
+ // Under atomic save, our FD now points to the old inode.
+ // Reopen the path to attach to the new file, then trigger reload.
+ _ = self.reopen(eventHandler: eventHandler)
+ }
+
+ // Debounce to avoid multiple loads per single save
+ if !self.pendingReload {
+ self.pendingReload = true
+ self.queue.asyncAfter(deadline: .now() + 0.15) {
+ self.pendingReload = false
+ eventHandler()
+ }
+ }
+ }
+
+ // Resume the new source immediately
+ src.resume()
+ source = src
+ return true
}
-
+
deinit {
Logger.shared.logDebug("Deinitializing file watcher")
- source?.cancel() // Clean up
- close(fileDescriptor)
+ source?.cancel() // Clean up; cancel handler closes fd
+ source = nil
+ // Don't close fileDescriptor here; cancel handler already did it.
+ fileDescriptor = -1
}
}
diff --git a/SupportCompanion/Helpers/HardwareInfoHelpers.swift b/SupportCompanion/Helpers/HardwareInfoHelpers.swift
new file mode 100644
index 0000000..ef96df7
--- /dev/null
+++ b/SupportCompanion/Helpers/HardwareInfoHelpers.swift
@@ -0,0 +1,31 @@
+//
+// HardwareInfoHelpers.swift
+// SupportCompanion
+//
+
+import Foundation
+import IOKit
+
+func getModelName() -> String {
+ return getPropertyValue(forKey: "product-name", service: "product") ?? "Unknown"
+}
+
+func getCPUName() -> String? {
+ var size: Int = 0
+ sysctlbyname("machdep.cpu.brand_string", nil, &size, nil, 0)
+ var cpuName = [CChar](repeating: 0, count: size)
+ sysctlbyname("machdep.cpu.brand_string", &cpuName, &size, nil, 0)
+ return String(cString: cpuName)
+}
+
+func getRAMSize() -> String {
+ let byteCount = ProcessInfo.processInfo.physicalMemory
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = .useGB
+ formatter.countStyle = .memory
+ return formatter.string(fromByteCount: Int64(byteCount))
+}
+
+func getSerialNumber() -> String {
+ return getPropertyValue(forKey: "IOPlatformSerialNumber", service: "IOPlatformExpertDevice") ?? "Unknown"
+}
diff --git a/SupportCompanion/Helpers/InstallerFileTypes.swift b/SupportCompanion/Helpers/InstallerFileTypes.swift
new file mode 100644
index 0000000..2961c3a
--- /dev/null
+++ b/SupportCompanion/Helpers/InstallerFileTypes.swift
@@ -0,0 +1,47 @@
+//
+// InstallerFileTypes.swift
+// SupportCompanion
+//
+
+import Foundation
+
+/// The installers this app knows how to open.
+///
+/// Declaring these in `CFBundleDocumentTypes` is what puts Support Companion in Finder's **Open With**
+/// menu for a `.pkg` or a `.dmg`, and what lets the Services item appear for them. It is deliberately
+/// all this app does about file types.
+///
+/// It used to also make itself the *default* handler, so that a plain double-click came here first.
+/// That is the only way macOS offers to decide per application before Installer.app asks for an
+/// administrator password — but setting a default handler raises a consent dialog per type that
+/// cannot be suppressed from code, and there is no way to be the handler for only some packages, so
+/// every installer on the Mac would have carried this app's icon. Neither is acceptable to put in
+/// front of someone at install time, so the user opens an installer here when they mean to, and
+/// everything else behaves exactly as it did before this app was on the Mac.
+enum InstallerFileTypes {
+
+ /// Declared in `CFBundleDocumentTypes` and in the Services item's `NSSendFileTypes`.
+ ///
+ /// `CFBundleDocumentTypes` declares the matching file extensions as well as these identifiers.
+ /// Binding by identifier alone is enough for a disk image but is not enough for a package: the
+ /// apps that do show up under **Open With** for a `.pkg`, such as Suspicious Package, bind `.pkg`
+ /// and `.mpkg` by extension, and doing only one of the two leaves the menu item missing.
+ static let contentTypes = [
+ "com.apple.installer-package-archive", // a flat .pkg, which is nearly all of them
+ "com.apple.installer-distribution-package", // one built by productbuild
+ "com.apple.installer-package", // the older bundle-shaped .pkg
+ "com.apple.installer-meta-package", // an .mpkg
+ "com.apple.disk-image-udif", // a .dmg
+ ]
+
+ static let fileExtensions: Set = ["pkg", "mpkg", "dmg"]
+
+ // The Services item in `NSServices` also declares `NSRequiredContext`, scoping it to Finder.
+ // Without a required context the item does not appear in Finder's contextual menu at all — every
+ // third-party service that does show up there declares one, whether it is Suspicious Package
+ // naming `com.apple.finder` or kitty naming `NSTextContent: FilePath`.
+ //
+ // Open With is a different matter and is closed to us for packages: Launch Services offers only
+ // Installer.app for a .pkg, whatever an app claims, which is why the Services item carries the
+ // whole right-click story for packages while a disk image also appears under Open With.
+}
diff --git a/SupportCompanion/Helpers/InstallerServiceMenu.swift b/SupportCompanion/Helpers/InstallerServiceMenu.swift
new file mode 100644
index 0000000..a6e7b70
--- /dev/null
+++ b/SupportCompanion/Helpers/InstallerServiceMenu.swift
@@ -0,0 +1,64 @@
+//
+// InstallerServiceMenu.swift
+// SupportCompanion
+//
+
+import AppKit
+import Foundation
+
+/// Shows or hides the "Install with Support Companion" item in Finder's contextual menu.
+///
+/// The item itself is declared in `NSServices` in the app's `Info.plist`, which is signed and cannot
+/// be edited at runtime — so whether it *appears* is controlled the same way System Settings controls
+/// it, through the `pbs` preference domain. Each service has an entry in `NSServicesStatus` holding
+/// `enabled_context_menu` and `enabled_services_menu`.
+///
+/// This is not API. The key naming it is built from the bundle identifier, the menu item's title and
+/// the message name, and a service that has never been toggled has no entry at all. Both are handled
+/// below, but if Apple changes the format the worst case is that the item stays visible — the feature
+/// still refuses to do anything when an administrator has not enabled it, because the helper decides
+/// that and not this.
+enum InstallerServiceMenu {
+
+ private static let domain = "pbs" as CFString
+ private static let statusKey = "NSServicesStatus" as CFString
+
+ /// The `NSMessage` from `Info.plist`, which is the stable part of the key.
+ private static let message = "installOpenedInstaller"
+
+ /// ` - - `, as `pbs` writes it.
+ private static var preferredKey: String {
+ let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.github.macadmins.SupportCompanion"
+ return "\(bundleIdentifier) - Install with Support Companion - \(message)"
+ }
+
+ static func apply(showing shouldShow: Bool) {
+ var status = (CFPreferencesCopyAppValue(statusKey, domain) as? [String: Any]) ?? [:]
+
+ let setting: [String: Any] = [
+ "enabled_context_menu": shouldShow,
+ "enabled_services_menu": shouldShow,
+ ]
+
+ // Update whatever macOS has already written for this service, whatever it decided to call it,
+ // rather than adding a second entry it will ignore.
+ let existing = status.keys.filter { $0.contains(message) }
+
+ for key in existing {
+ status[key] = setting
+ }
+
+ if existing.isEmpty {
+ status[preferredKey] = setting
+ }
+
+ CFPreferencesSetAppValue(statusKey, status as CFDictionary, domain)
+ CFPreferencesAppSynchronize(domain)
+
+ NSUpdateDynamicServices()
+
+ Logger.shared.logDebug(
+ "Finder service item \(shouldShow ? "shown" : "hidden") via \(existing.isEmpty ? preferredKey : existing.joined(separator: ", "))"
+ )
+ }
+}
diff --git a/SupportCompanion/Helpers/JamfApps.swift b/SupportCompanion/Helpers/JamfApps.swift
new file mode 100644
index 0000000..2979749
--- /dev/null
+++ b/SupportCompanion/Helpers/JamfApps.swift
@@ -0,0 +1,566 @@
+//
+// JamfApps.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-10-13.
+//
+
+import Foundation
+
+extension DateFormatter {
+ static let shortDayMonth: DateFormatter = {
+ let df = DateFormatter()
+ df.locale = Locale(identifier: "en_US_POSIX") // stable month abbreviations
+ df.setLocalizedDateFormatFromTemplate("d MMM")
+ return df
+ }()
+}
+
+// MARK: - Date helpers (CoreData XML uses absolute seconds since 2001-01-01 a lot)
+extension Date {
+ static func fromCoreDataEpochSeconds(_ s: String) -> Date? {
+ guard let d = Double(s) else { return nil }
+ return Date(timeIntervalSinceReferenceDate: d) // 2001-01-01 00:00:00 +0000
+ }
+}
+
+extension String {
+ var normalizedAppName: String {
+ trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
+ }
+}
+
+// MARK: - Tiny XML parser (sax-style)
+
+final class SSPlusParser: NSObject, XMLParserDelegate {
+ private(set) var policies: [Policy] = []
+ private(set) var patches: [Patch] = []
+
+ // parser state
+ private var currentObjectType: String? // SSPOLICY, SSPATCH, ...
+ private var currentAttributes: [String:String] = [:]
+ private var currentAttributeName: String?
+ private var currentText = ""
+
+ func parse() async -> Bool {
+ let path = (Constants.Paths.jamfSelfServiceData as NSString).expandingTildeInPath
+ guard let data = FileManager.default.contents(atPath: path) else {
+ Logger.shared.logError("Failure to read storedata file")
+ return false
+ }
+ policies.removeAll()
+ patches.removeAll()
+ let p = XMLParser(data: data)
+ p.delegate = self
+ return p.parse()
+ }
+
+ // MARK: XMLParserDelegate
+
+ func parser(_ parser: XMLParser, didStartElement name: String,
+ namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
+
+ if name == "object" {
+ currentObjectType = attributeDict["type"] // e.g. "SSPOLICY"
+ currentAttributes = [:]
+ } else if name == "attribute" {
+ currentAttributeName = attributeDict["name"] // e.g. "name", "version"
+ currentText = ""
+ }
+ }
+
+ func parser(_ parser: XMLParser, foundCharacters string: String) {
+ currentText.append(string)
+ }
+
+ func parser(_ parser: XMLParser, didEndElement name: String,
+ namespaceURI: String?, qualifiedName qName: String?) {
+
+ if name == "attribute" {
+ if let key = currentAttributeName {
+ // Trim whitespace/newlines — sample has newlines and long spaces.
+ currentAttributes[key] = currentText.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ currentAttributeName = nil
+ currentText = ""
+ } else if name == "object" {
+ commitCurrentObject()
+ currentObjectType = nil
+ currentAttributes = [:]
+ }
+ }
+
+ private func commitCurrentObject() {
+ guard let t = currentObjectType else { return }
+
+ switch t {
+ case "SSPOLICY":
+ let name = (currentAttributes["name"] ?? "").normalizedAppName
+ // Extract "#### Version: X" from serverdescription (defensive)
+ let desc = currentAttributes["serverdescription"] ?? ""
+ let policyVersion = SSPlusParser.extractVersionFromPolicyDescription(desc)
+
+ let installedDate = Date.fromCoreDataEpochSeconds(currentAttributes["installedorupdateddate"] ?? "")
+ let installStatus = Int(currentAttributes["installstatus"] ?? "")
+ let iconUrl = currentAttributes["iconurl"] ?? ""
+ let id = Int(currentAttributes["id"] ?? "") ?? 0
+ let postInstallText = currentAttributes["postinstalltext"] ?? ""
+ // store policy info
+ policies.append(Policy(id: id,
+ name: name,
+ policyVersion: policyVersion,
+ installedOrUpdated: installedDate,
+ installStatus: installStatus,
+ iconUrl: iconUrl,
+ postInstallText: postInstallText))
+
+ case "SSPATCH":
+ let name = (currentAttributes["name"] ?? "").normalizedAppName
+ guard let version = currentAttributes["version"], !version.isEmpty else { return }
+
+ let id = Int(currentAttributes["id"] ?? "") ?? 0
+ let available = Date.fromCoreDataEpochSeconds(currentAttributes["availabledate"] ?? "")
+ let deadline = Date.fromCoreDataEpochSeconds(currentAttributes["deadline"] ?? "")
+ let button = currentAttributes["buttontext"]
+ let installStatus = Int(currentAttributes["installstatus"] ?? "")
+
+ patches.append(Patch(id: id,
+ name: name,
+ version: version,
+ availableDate: available,
+ deadlineDate: deadline,
+ buttonText: button,
+ installStatus: installStatus))
+
+ default:
+ break
+ }
+ }
+
+ private static func extractVersionFromPolicyDescription(_ text: String) -> String? {
+ // Example: "#### Version: 139.1.81.137\n\nBrave is ..."
+ // Simple, forgiving regex:
+ let pattern = #"(?i)\bversion:\s*([0-9A-Za-z\.\-\+_]+)"#
+ guard let r = try? NSRegularExpression(pattern: pattern) else { return nil }
+ let ns = text as NSString
+ if let m = r.firstMatch(in: text, range: NSMakeRange(0, ns.length)), m.numberOfRanges >= 2 {
+ return ns.substring(with: m.range(at: 1))
+ }
+ return nil
+ }
+}
+
+// MARK: - “Update available?” logic
+
+enum UpdateLabel: CustomStringConvertible {
+ case notYetAvailable(Date)
+ case dueBy(Date)
+ case overdue(Date)
+ case upToDate
+ case dueNoDeadline
+ case unknown(String)
+
+ var description: String {
+ switch self {
+ case .notYetAvailable(let a):
+ return "Not yet available (available at \(ISO8601DateFormatter().string(from: a)))"
+ case .dueBy(let d):
+ return "Due by \(ISO8601DateFormatter().string(from: d))"
+ case .overdue(let d):
+ return "OVERDUE since \(ISO8601DateFormatter().string(from: d))"
+ case .upToDate:
+ return "Up to date (by dates)"
+ case .dueNoDeadline:
+ return "Due (no deadline)"
+ case .unknown(let why):
+ return "Unknown (\(why))"
+ }
+ }
+}
+
+// Normalize version strings to compare semantic part only (e.g., "6.6.6" == "6.6.6 (67409)")
+private func normalizeVersionSemantic(_ v: String) -> String {
+ let trimmed = v.trimmingCharacters(in: .whitespacesAndNewlines)
+ // If there's a space or parenthesis, keep only the prefix before it.
+ if let idx = trimmed.firstIndex(where: { $0 == " " || $0 == "(" }) {
+ return String(trimmed[.. (needed: Bool, label: UpdateLabel) {
+
+ func due(_ deadline: Date?) -> UpdateLabel {
+ deadline.map { .dueBy($0) } ?? .dueNoDeadline
+ }
+
+ // 1. Missing availability → version-only check
+ guard let avail = patch.availableDate else {
+ if let pv = policy.policyVersion {
+ let pvNorm = normalizeVersionSemantic(pv)
+ let patchNorm = normalizeVersionSemantic(patch.version)
+ let mismatch = pvNorm != patchNorm
+ return (mismatch, mismatch ? due(nil) : .upToDate)
+ }
+ return (false, .unknown("missing availableDate"))
+ }
+
+ // 2. Overdue beats everything
+ if let d = patch.deadlineDate, now >= d {
+ return (true, .overdue(d))
+ }
+
+ // 3. Not yet available
+ if now < avail {
+ return (false, .notYetAvailable(avail))
+ }
+
+ // 4. Facts
+ let installedAfterAvail = (policy.installedOrUpdated ?? .distantPast) >= avail
+ let versionMatches: Bool? = policy.policyVersion.map {
+ normalizeVersionSemantic($0) == normalizeVersionSemantic(patch.version)
+ }
+
+ // 5. Installed after availability
+ if installedAfterAvail {
+ switch versionMatches {
+ case .some(false):
+ let pv = normalizeVersionSemantic(policy.policyVersion ?? "")
+ let pvRaw = policy.policyVersion ?? ""
+ let patchNorm = normalizeVersionSemantic(patch.version)
+ Logger.shared.logDebug("\(patch.name) is available now, but the installed version (\(pvRaw)) [normalized: \(pv)] doesn't match \(patch.version) [normalized: \(patchNorm)].")
+ return (true, due(patch.deadlineDate)) // known mismatch
+ default:
+ return (false, .upToDate) // match or nil → trust the date
+ }
+ }
+
+ // 6. Installed before availability or unknown install time
+ if let d = patch.deadlineDate {
+ return (true, .dueBy(d)) // deadline drives even without version
+ }
+
+ switch versionMatches {
+ case .some(true): return (false, .upToDate)
+ case .some(false): return (true, .dueNoDeadline)
+ case .none: return (false, .unknown("missing policyVersion past availability"))
+ }
+}
+
+// MARK: - Name matching
+
+/// Lowercased, punctuation-free tokens, for deciding whether two names mean the same application.
+///
+/// Used for patch-to-policy and for policy-to-bundle alike. That sharing is deliberate: the names Jamf
+/// uses for a patch title, for the policy that installed the app, and for the bundle on disk are all
+/// different — "Zoom Client for Meetings", "zoom.us", `zoom.us.app` — and a lookup that only handles
+/// one of those mismatches trades a missed update for a phantom one.
+func canonicalTokens(_ s: String) -> [String] {
+ // Lowercase and remove non-alphanumerics to get stable tokens
+ let lowered = s.lowercased()
+ let cleaned = lowered.replacingOccurrences(of: #"[^a-z0-9]+"#, with: " ", options: .regularExpression)
+ let tokens = cleaned.split(separator: " ").map(String.init)
+ // Remove very short/common tokens to reduce false positives
+ let stop: Set = ["client", "for", "the", "and", "app", "apps", "application", "meetings", "installer", "update", "patch"]
+ return tokens.filter { $0.count >= 3 && !stop.contains($0) }
+}
+
+/// How alike two names are, as the share of the shorter one's tokens that the longer also has.
+func nameSimilarity(_ a: String, _ b: String) -> Double {
+ let ta = Set(canonicalTokens(a))
+ let tb = Set(canonicalTokens(b))
+ guard !ta.isEmpty, !tb.isEmpty else { return 0 }
+
+ let denom = Double(min(ta.count, tb.count))
+ return denom > 0 ? Double(ta.intersection(tb).count) / denom : 0
+}
+
+/// Threshold tuned for distinctive names like "zoom", "slack", "chrome".
+let nameMatchThreshold = 0.5
+
+/// The application bundles on this Mac, by name.
+///
+/// `/Applications` and `~/Applications`, plus one level below each so that vendor folders and
+/// `Utilities` are covered. Anything kept outside those is not somewhere Jamf patching would be
+/// managing it from.
+func installedAppNames() -> [String] {
+ let fileManager = FileManager.default
+ let roots = ["/Applications", ("~/Applications" as NSString).expandingTildeInPath]
+ var names: [String] = []
+
+ for root in roots {
+ guard let entries = try? fileManager.contentsOfDirectory(atPath: root) else { continue }
+
+ for entry in entries {
+ if entry.hasSuffix(".app") {
+ names.append(String(entry.dropLast(4)))
+ continue
+ }
+
+ let nested = "\(root)/\(entry)"
+ var isDirectory: ObjCBool = false
+ guard fileManager.fileExists(atPath: nested, isDirectory: &isDirectory), isDirectory.boolValue
+ else { continue }
+
+ let children = (try? fileManager.contentsOfDirectory(atPath: nested)) ?? []
+ names.append(contentsOf: children.filter { $0.hasSuffix(".app") }.map { String($0.dropLast(4)) })
+ }
+ }
+
+ return names
+}
+
+/// How good the evidence is that a policy's application is on this Mac.
+///
+/// Ordered, because more than one policy can plausibly answer for the same app and the strongest
+/// evidence should win. Jamf lists a patch title as a policy of its own beside the policy that
+/// installed the app, and name matching cannot separate them — "Zoom Client for Meetings" and
+/// "zoom.us" both reduce to `zoom`, so both resemble `zoom.us.app` equally well. What separates them is
+/// that only one of them is Jamf's record of an installation.
+enum InstallEvidence: Int, Comparable {
+ /// Neither Jamf nor the Mac says the app is here.
+ case absent = 0
+ /// A matching bundle is present, but Jamf has no record of installing it — an app somebody
+ /// installed by hand. Its patches still apply.
+ case onDisk = 1
+ /// Jamf installed it, and the Mac agrees.
+ case installedByJamf = 2
+
+ static func < (lhs: InstallEvidence, rhs: InstallEvidence) -> Bool {
+ lhs.rawValue < rhs.rawValue
+ }
+}
+
+/// What we can tell about whether a policy's application is really here.
+///
+/// `installstatus` disagrees with the Mac in both directions, and each direction costs something
+/// different: an app installed by hand reads `0`, which used to hide its patches, and one the user has
+/// since dragged to the Trash still reads `4`, which offered an update for software that was not there.
+/// So the Mac gets the veto and Jamf gets the tie-break. Jamf's record is trusted outright only when
+/// the disk could not be read, since an empty index means the lookup failed rather than that the Mac
+/// has no applications.
+func installEvidence(for policy: Policy, among installedApps: [String]) -> InstallEvidence {
+ let claimedByJamf = (policy.installStatus ?? 0) == 4
+
+ guard !installedApps.isEmpty else { return claimedByJamf ? .installedByJamf : .absent }
+
+ let onDisk = installedApps.contains { nameSimilarity(policy.name, $0) >= nameMatchThreshold }
+ guard onDisk else { return .absent }
+
+ return claimedByJamf ? .installedByJamf : .onDisk
+}
+
+func computeUpdates(policies: [Policy],
+ patches: [Patch],
+ now: Date = Date(),
+ installedApps: [String]? = nil) async -> ([PendingJamfUpdate], Int, Int) {
+
+ let installed = installedApps ?? installedAppNames()
+
+ // Keep one policy per name: the newest (by installedOrUpdated date)
+ let policiesByName: [String: Policy] = Dictionary(grouping: policies, by: { $0.name })
+ .compactMapValues { group in
+ group.sorted {
+ let s0 = $0.installStatus ?? 0, s1 = $1.installStatus ?? 0
+ if s0 != s1 { return s0 > s1 }
+ return ($0.installedOrUpdated ?? .distantPast) > ($1.installedOrUpdated ?? .distantPast)
+ }.first
+ }
+
+ /// The policy a patch is about: the best evidence of an installation, then the closest name.
+ ///
+ /// The previous version took an exact name match unconditionally and only fell back to fuzzy
+ /// matching when there was none. That picked Jamf's listing of the patch title over the policy that
+ /// had actually installed the app, and the installed-app check then discarded the patch — which is
+ /// how a pending Zoom update went unreported.
+ func resolvePolicy(for patchName: String) -> Policy? {
+ guard !canonicalTokens(patchName).isEmpty else { return nil }
+
+ var best: (policy: Policy, evidence: InstallEvidence, score: Double, exact: Bool)?
+
+ for (policyName, policy) in policiesByName {
+ let exact = policyName == patchName
+ let score = exact ? 1.0 : nameSimilarity(patchName, policyName)
+ guard score >= nameMatchThreshold else { continue }
+
+ let evidence = installEvidence(for: policy, among: installed)
+ // Self Service lists policies for apps that are not installed, and Jamf publishes patches
+ // for them too. Offering an update for software that is not here would be a fresh install
+ // nobody asked for, so those are no candidate at all.
+ guard evidence > .absent else { continue }
+
+ guard let current = best else {
+ best = (policy, evidence, score, exact)
+ continue
+ }
+
+ // Evidence of an installation first, then name closeness, then an exact name, then
+ // recency — the same tie-break the fuzzy matcher used before.
+ let better: Bool
+ if evidence != current.evidence {
+ better = evidence > current.evidence
+ } else if score != current.score {
+ better = score > current.score
+ } else if exact != current.exact {
+ better = exact
+ } else {
+ better = (policy.installedOrUpdated ?? .distantPast)
+ > (current.policy.installedOrUpdated ?? .distantPast)
+ }
+
+ if better { best = (policy, evidence, score, exact) }
+ }
+
+ return best?.policy
+ }
+
+ var results: [PendingJamfUpdate] = []
+ var matchedPolicyNames = Set()
+ var updateCount = 0
+ var upToDateCount = 0
+
+ for patch in patches {
+ guard let policy = resolvePolicy(for: patch.name) else { continue }
+ matchedPolicyNames.insert(policy.name)
+
+ let (needed, label) = evaluateUpdate(policy: policy, patch: patch, now: now)
+ let details = "available=\(patch.availableDate?.description ?? "nil"), " +
+ "deadline=\(patch.deadlineDate?.description ?? "nil"), " +
+ "lastInstall=\(policy.installedOrUpdated?.description ?? "nil"), " +
+ "policyVersion=\(policy.policyVersion ?? "nil"), " +
+ "patchVersion=\(patch.version)"
+ if needed {
+ results.append(PendingJamfUpdate(
+ id: UUID(),
+ name: patch.name,
+ version: patch.version,
+ needsUpdate: needed,
+ label: label,
+ details: details,
+ showInfoIcon: !details.isEmpty,
+ dueBy: patch.deadlineDate.map { DateFormatter.shortDayMonth.string(from: $0) },
+ patchId: patch.id,
+ policyName: policy.name
+ ))
+ }
+
+ // Count how many updates we have versus how many apps are up to date
+ if needed {
+ updateCount += 1
+ } else {
+ upToDateCount += 1
+ }
+ }
+
+ // Fallback: if there are no patches (or some policies have no corresponding patch),
+ // treat installed policies (installStatus == 4) as up-to-date so the overall percent
+ // does not show 0% patched purely due to missing patch objects.
+ if patches.isEmpty {
+ for (_, policy) in policiesByName {
+ if installEvidence(for: policy, among: installed) > .absent {
+ upToDateCount += 1
+ }
+ }
+ } else {
+ // Also count installed policies that did not have a matching patch name.
+ for (name, policy) in policiesByName where !matchedPolicyNames.contains(name) {
+ if installEvidence(for: policy, among: installed) > .absent {
+ upToDateCount += 1
+ }
+ }
+ }
+
+ return (results, updateCount, upToDateCount)
+}
+
+func getInstalledJamfAppsFromStore() async -> [String: Any] {
+ let path = (Constants.Paths.jamfSelfServiceData as NSString).expandingTildeInPath
+ guard let _ = FileManager.default.contents(atPath: path) else {
+ Logger.shared.logError("Failure to read storedata file")
+ return [:]
+ }
+ var apps: [String: Any] = [:]
+ let parser = SSPlusParser()
+ let parsed = await parser.parse()
+ if !parsed {
+ Logger.shared.logError("Failed to parse Jamf storedata XML")
+ return [:]
+ }
+ for policy in parser.policies {
+ if policy.installStatus == 4 {
+ // verify the app exists in /Applications or ~/Applications
+ let appPath1 = "/Applications/\(policy.name).app"
+ let appPath2 = ("~/Applications/\(policy.name).app" as NSString).expandingTildeInPath
+ if !FileManager.default.fileExists(atPath: appPath1) && !FileManager.default.fileExists(atPath: appPath2) {
+ continue
+ }
+ // if policy.policyVersion is nil or empty, try to get version from app's Info.plist
+ var policyVersion = policy.policyVersion
+ if (policyVersion ?? "").isEmpty {
+ let plistPath = FileManager.default.fileExists(atPath: appPath1) ? "\(appPath1)/Contents/Info.plist" : "\(appPath2)/Contents/Info.plist"
+ policyVersion = getAppVersion(plistPath: plistPath)
+ }
+ // store app info
+ apps[policy.name] = [
+ "id": policy.id,
+ "name": policy.name,
+ "version": policyVersion ?? "",
+ "iconUrl": policy.iconUrl ?? "",
+ "postInstallText": policy.postInstallText ?? ""
+ ]
+ }
+ }
+ return apps
+}
+
+func downloadAppIcon(forApp: InstalledApp) async -> String {
+ guard let url = URL(string: forApp.iconUrl ?? "") else { return "" }
+
+ // first try to load from cache
+ let fileManager = FileManager.default
+ guard let appSupportDirectory = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else {
+ Logger.shared.logError("Failed to locate Application Support directory.")
+ return ""
+ }
+
+ let appSupportJamfDirURL = appSupportDirectory.appendingPathComponent("SupportCompanion/JamfIcons")
+ let cachedIcons = (try? fileManager.contentsOfDirectory(at: appSupportJamfDirURL, includingPropertiesForKeys: nil, options: [])) ?? []
+ for icon in cachedIcons {
+ if icon.lastPathComponent.contains(forApp.name) {
+ Logger.shared.logDebug("Using cached icon for \(forApp.name) at \(icon.path)")
+ return icon.path
+ }
+ }
+
+ // get icon data
+ guard let (iconData, response) = try? await URLSession.shared.data(from: url),
+ let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ Logger.shared.logError("Failed to download icon from \(forApp.iconUrl ?? "nil")")
+ return ""
+ }
+
+ // Ensure directory exists
+ if !fileManager.fileExists(atPath: appSupportJamfDirURL.path) {
+ do {
+ try fileManager.createDirectory(at: appSupportJamfDirURL, withIntermediateDirectories: true, attributes: nil)
+ } catch {
+ Logger.shared.logError("Failed to create app support dir: \(error.localizedDescription)")
+ return ""
+ }
+ }
+
+ // Save the icon data to a file
+ let iconFileURL = appSupportJamfDirURL.appendingPathComponent("\(forApp.name).png")
+ do {
+ Logger.shared.logDebug("Saving icon for \(forApp.name) to \(iconFileURL.path)")
+ try iconData.write(to: iconFileURL)
+ } catch {
+ Logger.shared.logError("Failed to save app icon: \(error.localizedDescription)")
+ return ""
+ }
+ return iconFileURL.path
+}
diff --git a/SupportCompanion/Helpers/JamfHelpers.swift b/SupportCompanion/Helpers/JamfHelpers.swift
new file mode 100644
index 0000000..93831f5
--- /dev/null
+++ b/SupportCompanion/Helpers/JamfHelpers.swift
@@ -0,0 +1,156 @@
+//
+// JamfHelpers.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-11-12.
+//
+
+import Foundation
+
+@MainActor
+func getLastCheckIn() async throws -> String {
+ let output = try await ExecutionService.jamfLog(
+ kind: .checkIn,
+ hours: AppStateManager.shared.preferences.jamfLogPollHours
+ )
+
+ let lines = output.split(whereSeparator: \.isNewline).map(String.init)
+ guard let lastLine = lines.reversed().first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
+ Logger.shared.logDebug("No log entry found for Jamf check-ins")
+ return "Unknown"
+ }
+
+ let parts = lastLine.split(separator: " ").map(String.init)
+ guard parts.count >= 2 else {
+ Logger.shared.logDebug("Unexpected log line format for Jamf check-ins: \(lastLine)")
+ return "Unknown"
+ }
+
+ let tsCandidate = parts[0] + " " + parts[1]
+ guard let tsDate = parseUnifiedLogTimestamp(tsCandidate) else {
+ Logger.shared.logDebug("Failed to parse timestamp from log line for Jamf check-ins: \(lastLine)")
+ return "Unknown"
+ }
+
+ Logger.shared.logDebug("Last Jamf check-in was on: \(tsDate)")
+ return timeAgoString(since: tsDate)
+}
+
+@MainActor
+func getLastInventoryUpdate() async throws -> String {
+ let output = try await ExecutionService.jamfLog(
+ kind: .inventory,
+ hours: AppStateManager.shared.preferences.jamfLogPollHours
+ )
+
+ let lines = output.split(whereSeparator: \.isNewline).map(String.init)
+ guard let lastLine = lines.reversed().first(where: { !$0.trimmingCharacters(in: .whitespaces).isEmpty }) else {
+ Logger.shared.logDebug("No log entry found for Jamf inventory update.")
+ return "Unknown"
+ }
+
+ let parts = lastLine.split(separator: " ").map(String.init)
+ guard parts.count >= 2 else {
+ Logger.shared.logDebug("Unexpected output format from Jamf log: \(lastLine)")
+ return "Unknown"
+ }
+
+ let tsCandidate = parts[0] + " " + parts[1]
+ guard let tsDate = parseUnifiedLogTimestamp(tsCandidate) else {
+ Logger.shared.logDebug("Failed to parse timestamp from Jamf log entry: \(lastLine)")
+ return "Unknown"
+ }
+
+ Logger.shared.logDebug("Last Jamf inventory update was on \(tsDate)")
+ return timeAgoString(since: tsDate)
+}
+
+func getJamfUrl() async throws -> String {
+ let prefPlist = "/Library/Preferences/com.jamfsoftware.jamf.plist"
+
+ guard FileManager.default.fileExists(atPath: prefPlist) else {
+ Logger.shared.logError("Could not find Jamf preferences plist at \(prefPlist). Is Jamf running?")
+ return "Unknown"
+ }
+
+ let args = ["read", prefPlist, "jss_url"]
+ let output: String
+ output = try await ExecutionService.executeCommand("/usr/bin/defaults", with: args)
+ return output
+}
+
+func getJamfId() async throws -> String {
+ let output = try await ExecutionService.jamfRecon()
+
+ if let rangeStart = output.range(of: ""),
+ let rangeEnd = output.range(of: " ", range: rangeStart.upperBound..\s*([0-9]+)\s*"#, options: []) {
+ let ns = output as NSString
+ let range = NSRange(location: 0, length: ns.length)
+ if let match = regex.firstMatch(in: output, options: [], range: range),
+ match.numberOfRanges >= 2 {
+ let id = ns.substring(with: match.range(at: 1))
+ if !id.isEmpty {
+ return id
+ }
+ }
+ }
+
+ Logger.shared.logError("Failed to parse Jamf computer_id from recon output")
+ return "Unknown"
+}
+
+private func parseUnifiedLogTimestamp(_ s: String) -> Date? {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.timeZone = TimeZone.current
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSSZZZZZ"
+ if let d = formatter.date(from: s) { return d }
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSZZZZZ"
+ if let d = formatter.date(from: s) { return d }
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSSSS"
+ if let d = formatter.date(from: s) { return d }
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
+ if let d = formatter.date(from: s) { return d }
+ return nil
+}
+
+private func timeAgoString(since date: Date, now: Date = Date()) -> String {
+ let seconds = Int(now.timeIntervalSince(date))
+ if seconds < 0 {
+ return Constants.General.justNow
+ }
+ if seconds < 60 {
+ if seconds == 1 {
+ return "1 \(Constants.General.second) \(Constants.General.ago)"
+ }
+ return "\(seconds) \(Constants.General.seconds) \(Constants.General.ago)"
+ }
+ let minutes = seconds / 60
+ if minutes < 60 {
+ if minutes == 1 {
+ return "1 \(Constants.General.minute) \(Constants.General.ago)"
+ }
+ return "\(minutes) \(Constants.General.minutes) \(Constants.General.ago)"
+ }
+ let hours = minutes / 60
+ if hours < 24 {
+ if hours == 1 {
+ return "1 \(Constants.General.hour) \(Constants.General.ago)"
+ }
+ return "\(hours) \(Constants.General.hours) \(Constants.General.ago)"
+ }
+ let days = hours / 24
+ if days == 1 {
+ return "1 \(Constants.General.day) \(Constants.General.ago)"
+ }
+ return "\(days) \(Constants.General.daysAgo)"
+}
diff --git a/SupportCompanion/Helpers/LogHelpers.swift b/SupportCompanion/Helpers/LogHelpers.swift
new file mode 100644
index 0000000..e28bd27
--- /dev/null
+++ b/SupportCompanion/Helpers/LogHelpers.swift
@@ -0,0 +1,65 @@
+//
+// LogHelpers.swift
+// SupportCompanion
+//
+
+import Foundation
+import AppKit
+
+extension ActionHelpers {
+ @MainActor static func gatherLogs(preferences: Preferences, completion: @escaping (OperationResult) -> Void) {
+ let command = buildZipCommand(for: preferences.logFolders, excluding: preferences.excludedLogFolders)
+ Logger.shared.logDebug("Gathering logs with command: \(command)")
+ Task {
+ do {
+ _ = try await ExecutionService.executeCommand("/bin/sh", with: ["-c", command])
+ Logger.shared.logDebug("Zip command executed successfully")
+
+ guard let selectedURL = await promptSaveLocation() else {
+ completion(.info(Constants.ToastMessages.InfoMessages.gatherLogsInfo))
+ return
+ }
+
+ try saveArchive(to: selectedURL)
+ completion(.success(selectedURL.path))
+ } catch {
+ Logger.shared.logError("Error gathering logs: \(error)")
+ completion(.failure(error))
+ }
+ }
+ }
+
+ private static func buildZipCommand(for logFolders: [String], excluding excludedLogFolders: [String]) -> String {
+ let fileManager = FileManager.default
+ let archivePath = Constants.Paths.tempArchivePath
+ if fileManager.fileExists(atPath: archivePath) {
+ _ = try? fileManager.removeItem(at: URL(fileURLWithPath: archivePath))
+ }
+ var command = "/usr/bin/zip -r \(archivePath)"
+ logFolders.forEach { command += " '\($0)'" }
+ if !excludedLogFolders.isEmpty {
+ command += " -x"
+ excludedLogFolders.forEach { command += " '\($0)/*'" }
+ }
+ return command
+ }
+
+ private static func saveArchive(to location: URL) throws {
+ let fileManager = FileManager.default
+ let archivePath = Constants.Paths.tempArchivePath
+ if fileManager.fileExists(atPath: location.path) {
+ try fileManager.removeItem(at: location)
+ }
+ try fileManager.copyItem(at: URL(fileURLWithPath: archivePath), to: location)
+ }
+
+ @MainActor
+ private static func promptSaveLocation() async -> URL? {
+ Logger.shared.logDebug("Prompting user to save logs")
+ let savePanel = NSSavePanel()
+ savePanel.title = Constants.Titles.saveLogs
+ savePanel.nameFieldStringValue = "supportcompanion_logs.zip"
+ let response = savePanel.runModal()
+ return response == .OK ? savePanel.url : nil
+ }
+}
diff --git a/SupportCompanion/Helpers/Logger.swift b/SupportCompanion/Helpers/Logger.swift
index 645f8d6..cd77607 100644
--- a/SupportCompanion/Helpers/Logger.swift
+++ b/SupportCompanion/Helpers/Logger.swift
@@ -6,32 +6,143 @@
//
import Foundation
-
import os
-class Logger {
+final class Logger {
static let shared = Logger()
+
+ // Unified Logging
private var logger: OSLog
+ // File logging configuration
+ private var fileLoggingEnabled = true
+ private var debugEnabled = false
+ private var logDirectoryURL: URL
+ private var logFileURL: URL
+ private var maxFileSizeBytes: Int = 5 * 1024 * 1024 // 5 MB
+ private var maxRotatedFiles: Int = 5
+ private let dateFormatter = ISO8601DateFormatter()
+
+ // File I/O isolation
+ private let fileQueue = DispatchQueue(label: "com.github.macadmins.SupportCompanion.LoggerFileQueue")
+
private init() {
+ // OSLog setup
let subsystem = Bundle.main.bundleIdentifier ?? "com.github.macadmins.SupportCompanion"
let category = "SupportCompanion"
logger = OSLog(subsystem: subsystem, category: category)
+
+ // Default file log path: /Library/Logs/SupportCompanion/SupportCompanion.log
+ // If you prefer per-user logs, change to .userDomainMask and ~/Library/Logs path.
+ let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first!
+ let dir = base.appendingPathComponent("Logs/SupportCompanion", isDirectory: true)
+ self.logDirectoryURL = dir
+ self.logFileURL = dir.appendingPathComponent("SupportCompanion.log")
+
+ // Ensure directory exists
+ try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
+ // MARK: - Configuration
+
func configure(subsystem: String, category: String) {
logger = OSLog(subsystem: subsystem, category: category)
}
+
+ func setFileDebugLogging(_ enabled: Bool) {
+ fileQueue.sync { self.debugEnabled = enabled }
+ }
+
+ // MARK: - Public logging API
func logInfo(_ message: String) {
os_log("%{public}@", log: logger, type: .info, message)
+ writeToFile(level: "INFO", message: message)
}
func logError(_ message: String) {
os_log("%{public}@", log: logger, type: .error, message)
+ writeToFile(level: "ERROR", message: message)
}
-
+
func logDebug(_ message: String) {
os_log("%{public}@", log: logger, type: .debug, message)
+ guard debugEnabled else { return }
+ writeToFile(level: "DEBUG", message: message)
+ }
+
+ // Optional: add warnings for parity with your other enum
+ func logWarning(_ message: String) {
+ os_log("%{public}@", log: logger, type: .default, message)
+ writeToFile(level: "WARNING", message: message)
+ }
+
+ // MARK: - File logging core
+
+ private func writeToFile(level: String, message: String) {
+ guard fileLoggingEnabled else { return }
+ let ts = dateFormatter.string(from: Date())
+
+ let line = "[\(ts)] [\(level)] \(message)\n"
+
+ fileQueue.async {
+ guard let data = line.data(using: .utf8) else { return }
+
+ // Ensure directory exists
+ if !FileManager.default.fileExists(atPath: self.logDirectoryURL.path) {
+ try? FileManager.default.createDirectory(at: self.logDirectoryURL, withIntermediateDirectories: true)
+ }
+
+ // Ensure file exists
+ if !FileManager.default.fileExists(atPath: self.logFileURL.path) {
+ FileManager.default.createFile(atPath: self.logFileURL.path, contents: nil)
+ }
+
+ // Append
+ if let handle = try? FileHandle(forWritingTo: self.logFileURL) {
+ do {
+ try handle.seekToEnd()
+ try handle.write(contentsOf: data)
+ } catch {
+ // Swallow file write errors to avoid recursion via logging
+ }
+ try? handle.close()
+ }
+
+ // Rotate if needed
+ self.rotateIfNeeded()
+ }
+ }
+
+ private func rotateIfNeeded() {
+ guard
+ let attrs = try? FileManager.default.attributesOfItem(atPath: logFileURL.path),
+ let size = attrs[.size] as? NSNumber,
+ size.intValue > maxFileSizeBytes
+ else { return }
+
+ // Shift old logs: .log.(n-1) -> .log.n
+ let baseName = logFileURL.deletingPathExtension().lastPathComponent
+ let ext = logFileURL.pathExtension.isEmpty ? "log" : logFileURL.pathExtension
+
+ func rotatedURL(_ index: Int) -> URL {
+ logDirectoryURL.appendingPathComponent("\(baseName).\(ext).\(index)")
+ }
+
+ for i in stride(from: maxRotatedFiles - 1, through: 1, by: -1) {
+ let src = rotatedURL(i)
+ let dst = rotatedURL(i + 1)
+ if FileManager.default.fileExists(atPath: src.path) {
+ try? FileManager.default.removeItem(at: dst)
+ try? FileManager.default.moveItem(at: src, to: dst)
+ }
+ }
+
+ let first = rotatedURL(1)
+ try? FileManager.default.removeItem(at: first)
+ try? FileManager.default.moveItem(at: logFileURL, to: first)
+
+ // Create a fresh file
+ FileManager.default.createFile(atPath: logFileURL.path, contents: nil)
}
}
diff --git a/SupportCompanion/Helpers/MDMHelpers.swift b/SupportCompanion/Helpers/MDMHelpers.swift
index e1c04a2..27c5cc2 100644
--- a/SupportCompanion/Helpers/MDMHelpers.swift
+++ b/SupportCompanion/Helpers/MDMHelpers.swift
@@ -8,26 +8,18 @@
import Foundation
func getMDMEnrollmentTime() async -> String {
- let command = """
- /usr/bin/profiles -P -v | grep -A 10 'Management Profile'
- """
+ // Reading the enrollment profile needs root, so the helper does it and returns just the date.
do {
- let commandOutput = try await ExecutionService.executeCommandPrivileged("/bin/bash", arguments: ["-c", command])
-
- // Process the command output
- let lines = commandOutput.split(separator: "\n")
- for line in lines {
- if line.contains("installationDate") {
- let datePattern = #"(\d{4}-\d{2}-\d{2})"#
- if let range = line.range(of: datePattern, options: .regularExpression) {
- return String(line[range])
- }
- }
+ let installDate = try await ExecutionService.mdmEnrollmentDate()
+ if !installDate.isEmpty {
+ Logger.shared.logDebug("MDM enrollment profile installed \(installDate)")
+ return installDate
}
+ Logger.shared.logDebug("No MDM enrollment profile install date found")
} catch {
Logger.shared.logError("Error getting MDM enrollment time: \(error)")
}
-
+
return "Unknown"
}
diff --git a/SupportCompanion/Helpers/ManagementAppHelpers.swift b/SupportCompanion/Helpers/ManagementAppHelpers.swift
new file mode 100644
index 0000000..922c7b9
--- /dev/null
+++ b/SupportCompanion/Helpers/ManagementAppHelpers.swift
@@ -0,0 +1,71 @@
+//
+// ManagementAppHelpers.swift
+// SupportCompanion
+//
+
+import AppKit
+import Foundation
+
+extension ActionHelpers {
+ static func openSystemUpdates() {
+ Task {
+ do {
+ Logger.shared.logDebug("Opening system updates")
+ try await _ = ExecutionService.executeCommand("open", with: [Constants.Panels.softwareUpdates])
+ } catch {
+ Logger.shared.logError("Failed to open system updates: \(error)")
+ }
+ }
+ }
+
+ static func openBackgroundSecurityImprovements() {
+ Task {
+ do {
+ Logger.shared.logDebug("Opening background security improvements")
+ try await _ = ExecutionService.executeCommand("open", with: [Constants.Panels.backgroundSecurityImprovements])
+ } catch {
+ Logger.shared.logError("Failed to open background security improvements: \(error)")
+ }
+ }
+ }
+
+ static func openManagementApp(appURL: String) {
+ // This app's own pages (Fleet's apps page) open here rather than in whichever copy Launch Services picks
+ if let url = URL(string: appURL), url.scheme == "supportcompanion" {
+ Task { @MainActor in
+ NSApp.delegate?.application?(NSApp, open: [url])
+ }
+ return
+ }
+ Task {
+ do {
+ Logger.shared.logDebug("Opening Managed Software Center")
+ try await _ = ExecutionService.executeCommand("open", with: [appURL])
+ } catch {
+ Logger.shared.logError("Failed to open Managed Software Center: \(error)")
+ }
+ }
+ }
+
+ static func openSupportPage(url: String) {
+ Task {
+ do {
+ Logger.shared.logDebug("Opening support page \(url)")
+ try await _ = ExecutionService.executeCommand("open", with: [url])
+ } catch {
+ Logger.shared.logError("Failed to open support page \(url): \(error)")
+ }
+ }
+ }
+
+ static func openUserPanel() {
+ Task {
+ do {
+ Logger.shared.logDebug("Opening Users & Groups")
+ try await _ = ExecutionService.executeCommand("open", with: [Constants.Panels.users])
+ } catch {
+ Logger.shared.logError("Failed to open Users & Groups: \(error)")
+ }
+ }
+ }
+}
diff --git a/SupportCompanion/Helpers/NetworkInfoHelpers.swift b/SupportCompanion/Helpers/NetworkInfoHelpers.swift
new file mode 100644
index 0000000..0cdf741
--- /dev/null
+++ b/SupportCompanion/Helpers/NetworkInfoHelpers.swift
@@ -0,0 +1,90 @@
+//
+// NetworkInfoHelpers.swift
+// SupportCompanion
+//
+
+import Foundation
+import Network
+import CoreWLAN
+
+func getAllIPAddresses() -> [String] {
+ var ipAddresses: [String] = []
+ var ifaddr: UnsafeMutablePointer?
+
+ if getifaddrs(&ifaddr) == 0 {
+ var ptr = ifaddr
+ while ptr != nil {
+ defer { ptr = ptr?.pointee.ifa_next }
+
+ guard let interface = ptr?.pointee else {
+ Logger.shared.logError("Failed to get interface information")
+ return ipAddresses
+ }
+ let addrFamily = interface.ifa_addr.pointee.sa_family
+
+ if addrFamily == UInt8(AF_INET) {
+ if let currentInterfaceName = interface.ifa_name {
+ let name = String(cString: currentInterfaceName)
+ if name == "en0" || name.hasPrefix("en") {
+ var addr = interface.ifa_addr.pointee
+ var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
+ getnameinfo(&addr, socklen_t(interface.ifa_addr.pointee.sa_len), &hostname, socklen_t(hostname.count), nil, 0, NI_NUMERICHOST)
+ ipAddresses.append(String(cString: hostname))
+ }
+ }
+ }
+ }
+ freeifaddrs(ifaddr)
+ }
+ return ipAddresses
+}
+
+func getSSID() async -> String? {
+ guard let wifi = CWWiFiClient.shared().interface() else { return "WiFi Off" }
+ guard wifi.powerOn() else { return "WiFi Off" }
+
+ _ = try? await ExecutionService.setIPConfigVerbose(true)
+
+ do {
+ let command = "/usr/sbin/ipconfig getsummary en0 | awk -F ' SSID : ' '/ SSID : / {print $2}'"
+ let ssid = try await ExecutionService.executeCommand("/bin/sh", with: ["-c", command])
+ _ = try? await ExecutionService.setIPConfigVerbose(false)
+ let trimmed = ssid.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed == "" { return nil }
+ return trimmed.isEmpty ? nil : trimmed
+ } catch {
+ Logger.shared.logError("Failed to fetch SSID: \(error)")
+ _ = try? await ExecutionService.setIPConfigVerbose(false)
+ return nil
+ }
+}
+
+class IPAddressMonitor {
+ private static var monitor = NWPathMonitor()
+ private static let queue = DispatchQueue.global(qos: .background)
+ private static var lastUpdateTime: Date?
+ private static var lastIPs: [String] = []
+
+ struct NetworkStatus {
+ let ipAddresses: [String]
+ let ssid: String?
+ }
+
+ static func startMonitoring(onChange: @escaping (NetworkStatus) async -> Void) {
+ monitor = NWPathMonitor()
+ monitor.pathUpdateHandler = { path in
+ let currentIPs = path.status == .satisfied ? getAllIPAddresses() : [String]()
+ guard currentIPs.sorted() != lastIPs.sorted() else { return }
+ lastIPs = currentIPs
+ Task {
+ let currentSSID = path.status == .satisfied ? await getSSID() : nil
+ await onChange(NetworkStatus(ipAddresses: currentIPs, ssid: currentSSID))
+ }
+ }
+ monitor.start(queue: queue)
+ }
+
+ static func stopMonitoring() {
+ monitor.cancel()
+ }
+}
diff --git a/SupportCompanion/Helpers/ObservationHelpers.swift b/SupportCompanion/Helpers/ObservationHelpers.swift
new file mode 100644
index 0000000..8cd23fe
--- /dev/null
+++ b/SupportCompanion/Helpers/ObservationHelpers.swift
@@ -0,0 +1,52 @@
+//
+// ObservationHelpers.swift
+// SupportCompanion
+//
+
+import Foundation
+import Observation
+
+/// Keeps an observation started by `observeChanges(of:onChange:)` alive. Call `cancel()` to stop it.
+@MainActor
+final class ObservationToken {
+ fileprivate var isCancelled = false
+
+ func cancel() {
+ isCancelled = true
+ }
+}
+
+/// Calls `onChange` whenever the value returned by `value` changes, for code outside SwiftUI views
+/// (the replacement for subscribing to `@Published` publishers).
+///
+/// `withObservationTracking` only reports the first change, so this re-registers after each one.
+/// `onChange` runs on the main actor after the change, and only when the value actually differs.
+@MainActor
+@discardableResult
+func observeChanges(
+ of value: @escaping @MainActor () -> Value,
+ onChange: @escaping @MainActor (Value) -> Void
+) -> ObservationToken {
+ let token = ObservationToken()
+ var lastValue = value()
+
+ func track() {
+ withObservationTracking {
+ _ = value()
+ } onChange: {
+ // Fires before the property is updated, so read the new value on the next main actor turn
+ Task { @MainActor in
+ guard !token.isCancelled else { return }
+ let newValue = value()
+ if newValue != lastValue {
+ lastValue = newValue
+ onChange(newValue)
+ }
+ track()
+ }
+ }
+ }
+
+ track()
+ return token
+}
diff --git a/SupportCompanion/Helpers/ProcessRunner.swift b/SupportCompanion/Helpers/ProcessRunner.swift
new file mode 100644
index 0000000..c69729f
--- /dev/null
+++ b/SupportCompanion/Helpers/ProcessRunner.swift
@@ -0,0 +1,78 @@
+//
+// ProcessRunner.swift
+// SupportCompanion
+//
+// Shared by the app and the privileged helper.
+//
+
+import Foundation
+
+enum ProcessRunner {
+
+ private final class DataBox: @unchecked Sendable {
+ var data = Data()
+ }
+
+ /// Runs `command` through `/usr/bin/env` and returns its standard output.
+ /// Throws when the command exits with a non-zero status, including its standard error.
+ static func runCommand(_ command: String, with arguments: [String] = []) async throws -> String {
+ let result = try await run(executableURL: URL(fileURLWithPath: "/usr/bin/env"), arguments: [command] + arguments)
+
+ if result.status != 0 {
+ let errorOutput = String(data: result.error, encoding: .utf8) ?? "Unknown error"
+ throw NSError(
+ domain: "ExecutionServiceError",
+ code: Int(result.status),
+ userInfo: [
+ NSLocalizedDescriptionKey: "Command '\(command)' failed with status \(result.status): \(errorOutput)"
+ ]
+ )
+ }
+
+ return String(data: result.output, encoding: .utf8) ?? ""
+ }
+
+ /// Runs a process and collects its output.
+ ///
+ /// The pipes are drained while the process runs. Waiting for exit before reading deadlocks as soon
+ /// as a command writes more than the pipe buffer (~64KB): the child blocks on write and never exits.
+ /// The blocking work happens on a GCD thread rather than the Swift concurrency thread pool.
+ static func run(executableURL: URL, arguments: [String]) async throws -> (status: Int32, output: Data, error: Data) {
+ try await withCheckedThrowingContinuation { continuation in
+ DispatchQueue.global(qos: .userInitiated).async {
+ let process = Process()
+ process.executableURL = executableURL
+ process.arguments = arguments
+ let outputPipe = Pipe()
+ let errorPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = errorPipe
+
+ do {
+ try process.run()
+ } catch {
+ continuation.resume(throwing: error)
+ return
+ }
+
+ let output = DataBox()
+ let errorOutput = DataBox()
+ let group = DispatchGroup()
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ output.data = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ group.leave()
+ }
+ group.enter()
+ DispatchQueue.global(qos: .userInitiated).async {
+ errorOutput.data = errorPipe.fileHandleForReading.readDataToEndOfFile()
+ group.leave()
+ }
+ group.wait()
+ process.waitUntilExit()
+
+ continuation.resume(returning: (process.terminationStatus, output.data, errorOutput.data))
+ }
+ }
+ }
+}
diff --git a/SupportCompanion/Helpers/RebootHelpers.swift b/SupportCompanion/Helpers/RebootHelpers.swift
new file mode 100644
index 0000000..4c5c02d
--- /dev/null
+++ b/SupportCompanion/Helpers/RebootHelpers.swift
@@ -0,0 +1,39 @@
+//
+// RebootHelpers.swift
+// SupportCompanion
+//
+
+import Foundation
+
+extension ActionHelpers {
+ static func reboot(completion: @escaping (OperationResult) -> Void) async {
+ cancelShutdown()
+
+ try? await Task.sleep(nanoseconds: 200_000_000) // 200ms delay
+
+ Task { @MainActor in
+ Logger.shared.logDebug("Preparing to reboot")
+ completion(.info(""))
+ }
+
+ do {
+ _ = try await ExecutionService.reboot()
+ Logger.shared.logDebug("Reboot command executed")
+ } catch {
+ if (error as NSError).domain == NSCocoaErrorDomain && (error as NSError).code == NSUserCancelledError {
+ Logger.shared.logDebug("Reboot task was canceled")
+ completion(.info("Reboot operation canceled by user"))
+ } else {
+ Logger.shared.logError("Failed to reboot: \(error)")
+ completion(.failure(error))
+ }
+ }
+ }
+
+ static func cancelShutdown() {
+ Task {
+ _ = try? await ExecutionService.cancelReboot()
+ Logger.shared.logDebug("Cancel reboot command executed")
+ }
+ }
+}
diff --git a/SupportCompanion/Helpers/SSOInfoHelpers.swift b/SupportCompanion/Helpers/SSOInfoHelpers.swift
index ffe108d..bf82e22 100644
--- a/SupportCompanion/Helpers/SSOInfoHelpers.swift
+++ b/SupportCompanion/Helpers/SSOInfoHelpers.swift
@@ -16,7 +16,7 @@ class SSOInfoHelpers {
// Fetch Kerberos SSO info for the realm
let kerberosSSOOutput = try await ExecutionService.executeCommand("/usr/bin/app-sso", with: ["-i", realmName])
guard let kerberosSSOData = kerberosSSOOutput.data(using: .utf8),
- let kerberosSSOInfo = try PropertyListSerialization.propertyList(from: kerberosSSOData, options: [], format: nil) as? [String: Any] else {
+ let kerberosSSOInfo = try PropertyListSerialization.propertyList(from: kerberosSSOData, options: [], format: nil) as? [String: Any] else {
throw NSError(domain: "SSOHelper", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse Kerberos SSO plist"])
}
@@ -50,7 +50,7 @@ class SSOInfoHelpers {
// Extract device configuration
let deviceConfigJSON = extractJSON(from: platformSSOOutput, pattern: deviceConfigPattern)
guard let deviceConfigData = deviceConfigJSON?.data(using: .utf8),
- let deviceConfig = try? JSONSerialization.jsonObject(with: deviceConfigData, options: []) as? [String: Any] else {
+ let deviceConfig = try? JSONSerialization.jsonObject(with: deviceConfigData, options: []) as? [String: Any] else {
Logger.shared.logError("Device configuration parsing failed")
throw NSError(domain: "SSOHelper", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse device configuration"])
}
@@ -59,7 +59,7 @@ class SSOInfoHelpers {
let userConfigJSON = extractJSON(from: platformSSOOutput, pattern: userConfigPattern)
let userConfig: [String: Any]
if let userConfigData = userConfigJSON?.data(using: .utf8),
- let parsedUserConfig = try? JSONSerialization.jsonObject(with: userConfigData, options: []) as? [String: Any] {
+ let parsedUserConfig = try? JSONSerialization.jsonObject(with: userConfigData, options: []) as? [String: Any] {
userConfig = parsedUserConfig
} else {
Logger.shared.logError("User configuration parsing failed. Using default values.")
@@ -105,7 +105,7 @@ class SSOInfoHelpers {
// Helper to calculate days since a date
private func daysSince(from dateString: String?) -> Int {
guard let dateString = dateString,
- let date = ISO8601DateFormatter().date(from: dateString) else {
+ let date = ISO8601DateFormatter().date(from: dateString) else {
return -1
}
return Calendar.current.dateComponents([.day], from: date, to: Date()).day ?? -1
@@ -114,7 +114,7 @@ class SSOInfoHelpers {
// Helper to calculate days until a date
private func daysUntil(from dateString: String?) -> Int {
guard let dateString = dateString,
- let date = ISO8601DateFormatter().date(from: dateString) else {
+ let date = ISO8601DateFormatter().date(from: dateString) else {
return -1
}
return Calendar.current.dateComponents([.day], from: Date(), to: date).day ?? -1
@@ -128,7 +128,7 @@ class SSOInfoHelpers {
}
let range = NSRange(text.startIndex.. Bool {
return false
}
+@MainActor
class StorageMonitor {
static let shared = StorageMonitor()
- private var timer: Timer?
private var updateHandler: ((Double) -> Void)?
+ private var monitorTask: Task?
private init() {}
@@ -69,16 +70,17 @@ class StorageMonitor {
stopMonitoring() // Stop any existing timer
self.updateHandler = onUpdate
- timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
- let usagePercentage = getStorageUsagePercentage()
- DispatchQueue.main.async {
+ monitorTask = Task {
+ while !Task.isCancelled {
+ let usagePercentage = getStorageUsagePercentage()
self.updateHandler?(usagePercentage)
+ try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
}
}
}
func stopMonitoring() {
- timer?.invalidate()
- timer = nil
+ monitorTask?.cancel()
+ monitorTask = nil
}
}
diff --git a/SupportCompanion/Helpers/SystemProfilerApps.swift b/SupportCompanion/Helpers/SystemProfilerApps.swift
index 7d4aa09..efb08d1 100644
--- a/SupportCompanion/Helpers/SystemProfilerApps.swift
+++ b/SupportCompanion/Helpers/SystemProfilerApps.swift
@@ -16,7 +16,7 @@ class SystemProfilerApplications {
// Run the command to fetch installed apps in JSON format
do {
- appsJson = try await ExecutionService.executeCommandToFile("/usr/sbin/system_profiler", with: ["SPApplicationsDataType", "-json"])
+ appsJson = try await ExecutionService.executeCommand("/usr/sbin/system_profiler", with: ["SPApplicationsDataType", "-json"])
} catch {
Logger.shared.logError("Failed to get installed apps: \(error.localizedDescription)")
}
diff --git a/SupportCompanion/Helpers/TrustedPreferences.swift b/SupportCompanion/Helpers/TrustedPreferences.swift
new file mode 100644
index 0000000..eecb381
--- /dev/null
+++ b/SupportCompanion/Helpers/TrustedPreferences.swift
@@ -0,0 +1,61 @@
+//
+// TrustedPreferences.swift
+// SupportCompanion
+//
+
+import Foundation
+
+/// Reads settings that gate privileged behavior (root actions, admin elevation, allowed installers).
+///
+/// Any user can write to their own defaults domain with `defaults write`, and `UserDefaults.standard`
+/// happily returns those values. For settings that decide what runs as root, only keys forced by an
+/// MDM configuration profile are honored. Anything else falls back to the secure default.
+///
+/// `/Library/Preferences/com.github.macadmins.SupportCompanion.plist` used to count as well, on the
+/// grounds that only root can write it. That is true and is the problem: anything that reaches root
+/// once — a privileged action, somebody inside an elevation window — could write itself a permanent
+/// grant of elevation and of the installer allowlist, and nothing would ever put it back. A profile
+/// is owned by the MDM, which re-applies it. See `HelperPreferences`, which makes the same choice on
+/// the root side and is the copy that actually decides anything.
+enum TrustedPreferences {
+ private static let domain = "com.github.macadmins.SupportCompanion" as CFString
+
+ static func object(forKey key: String) -> Any? {
+ if UserDefaults.standard.objectIsForced(forKey: key) {
+ return UserDefaults.standard.object(forKey: key)
+ }
+
+ warnIfPresentUnmanaged(key)
+
+ return nil
+ }
+
+ /// Say when a setting is being ignored because of where it lives.
+ ///
+ /// Silently falling back to a default looks like the feature breaking for no reason to anyone who
+ /// configured this with something other than an MDM.
+ private static func warnIfPresentUnmanaged(_ key: String) {
+ let unmanaged = CFPreferencesCopyValue(key as CFString, domain, kCFPreferencesAnyUser, kCFPreferencesCurrentHost)
+ ?? CFPreferencesCopyValue(key as CFString, domain, kCFPreferencesAnyUser, kCFPreferencesAnyHost)
+
+ guard unmanaged != nil else { return }
+
+ Logger.shared.logError(
+ "Ignoring '\(key)' from /Library/Preferences: settings that grant privileges are only read from a configuration profile. Deliver it through your MDM."
+ )
+ }
+
+ static func bool(forKey key: String, default defaultValue: Bool) -> Bool {
+ let value = object(forKey: key)
+ return (value as? Bool) ?? (value as? NSNumber)?.boolValue ?? defaultValue
+ }
+
+ static func int(forKey key: String, default defaultValue: Int) -> Int {
+ let value = object(forKey: key)
+ return (value as? Int) ?? (value as? NSNumber)?.intValue ?? defaultValue
+ }
+
+ static func string(forKey key: String, default defaultValue: String) -> String {
+ object(forKey: key) as? String ?? defaultValue
+ }
+}
diff --git a/SupportCompanion/Helpers/UserHelpers.swift b/SupportCompanion/Helpers/UserHelpers.swift
index 9a1ac92..ebb4916 100644
--- a/SupportCompanion/Helpers/UserHelpers.swift
+++ b/SupportCompanion/Helpers/UserHelpers.swift
@@ -6,48 +6,43 @@
//
import Foundation
+import OpenDirectory
class UserInfoHelper {
- private let loginNamePattern = #"Login: (\S+)"#
- private let namePattern = #"Name: (.+)"#
- private let homeDirPattern = #"Directory: (\S+)"#
- private let shellPattern = #"Shell: (\S+)"#
func fetchUserInfo() async throws -> UserInfo {
- // Get the current username
- let currentUser = NSUserName() // Fetches the current login username
-
- // Fetch basic user info with `finger`
- let userOutput = try await ExecutionService.executeCommand("/usr/bin/finger", with: [currentUser])
-
- guard !userOutput.isEmpty else {
- throw NSError(domain: "UserInfoHelper", code: -1, userInfo: [NSLocalizedDescriptionKey: "No output from finger command"])
- }
-
- // Extract user information using regex patterns
- let login = extractMatch(from: userOutput, with: loginNamePattern) ?? currentUser
- let name = extractMatch(from: userOutput, with: namePattern) ?? "Unknown"
- let homeDir = extractMatch(from: userOutput, with: homeDirPattern) ?? "Unknown"
- let shell = extractMatch(from: userOutput, with: shellPattern) ?? "Unknown"
-
- // Check admin status with `dscl`
- let isAdmin = try await checkAdminStatus(for: login)
-
- return UserInfo(login: login, name: name, homeDir: homeDir, shell: shell, isAdmin: isAdmin)
- }
-
- private func extractMatch(from text: String, with pattern: String) -> String? {
- let regex = try? NSRegularExpression(pattern: pattern)
- let range = NSRange(text.startIndex.. Bool {
- let adminGroupOutput = try await ExecutionService.executeCommand("/usr/bin/dscl", with: [".", "-read", "/Groups/admin", "GroupMembership"])
- return adminGroupOutput.contains(user)
+ try await Task.detached(priority: .utility) {
+ let username = NSUserName()
+
+ let session = ODSession.default()
+ let node = try ODNode(session: session, type: UInt32(kODNodeTypeLocalNodes))
+
+ let userRecord = try node.record(
+ withRecordType: kODRecordTypeUsers,
+ name: username,
+ attributes: kODAttributeTypeStandardOnly
+ )
+
+ let name = try userRecord.values(forAttribute: kODAttributeTypeFullName).first as? String ?? username
+ let homeDir = try userRecord.values(forAttribute: kODAttributeTypeNFSHomeDirectory).first as? String ?? ""
+ let shell = try userRecord.values(forAttribute: kODAttributeTypeUserShell).first as? String ?? ""
+
+ let adminRecord = try node.record(
+ withRecordType: kODRecordTypeGroups,
+ name: "admin",
+ attributes: kODAttributeTypeStandardOnly
+ )
+
+ let members = try adminRecord.values(forAttribute: kODAttributeTypeGroupMembership) as? [String] ?? []
+ let isAdmin = members.contains(username)
+
+ return UserInfo(
+ login: username,
+ name: name,
+ homeDir: homeDir,
+ shell: shell,
+ isAdmin: isAdmin
+ )
+ }.value
}
}
diff --git a/SupportCompanion/Info.plist b/SupportCompanion/Info.plist
index 3ada032..a9186f3 100644
--- a/SupportCompanion/Info.plist
+++ b/SupportCompanion/Info.plist
@@ -1,42 +1,110 @@
-
- CFBundleDevelopmentRegion
- $(DEVELOPMENT_LANGUAGE)
- CFBundleExecutable
- $(EXECUTABLE_NAME)
- CFBundleIdentifier
- $(PRODUCT_BUNDLE_IDENTIFIER)
- CFBundleInfoDictionaryVersion
- 6.0
- CFBundleName
- $(PRODUCT_NAME)
- CFBundlePackageType
- $(PRODUCT_BUNDLE_PACKAGE_TYPE)
- CFBundleIconFile
- AppIcon
- LSMinimumSystemVersion
- $(MACOSX_DEPLOYMENT_TARGET)
- CFBundleShortVersionString
- 2.3.1
- CFBundleVersion
- 2.3.1
- CFBundleURLTypes
-
-
- CFBundleURLName
- com.github.macadmins.SupportCompanion
- CFBundleURLSchemes
-
- supportcompanion
-
-
-
- SMPrivilegedExecutables
-
- com.github.macadmins.SupportCompanion.helper
- anchor apple generic and identifier "com.github.macadmins.SupportCompanion.helper" and (certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = $(TEAM_ID))
-
-
-
\ No newline at end of file
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIconFile
+ AppIcon
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ 3.0.0
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Installer Package
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ pkg
+ mpkg
+
+ LSItemContentTypes
+
+ com.apple.installer-package-archive
+ com.apple.installer-distribution-package
+ com.apple.installer-package
+ com.apple.installer-meta-package
+
+
+
+ CFBundleTypeName
+ Disk Image
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ CFBundleTypeExtensions
+
+ dmg
+
+ LSItemContentTypes
+
+ com.apple.disk-image-udif
+
+
+
+ NSServices
+
+
+ NSMenuItem
+
+ default
+ Install with Support Companion
+
+ NSMessage
+ installOpenedInstaller
+ NSPortName
+ SupportCompanion
+ NSRequiredContext
+
+ NSApplicationIdentifier
+
+ com.apple.finder
+
+
+ NSSendFileTypes
+
+ com.apple.installer-package-archive
+ com.apple.installer-distribution-package
+ com.apple.installer-package
+ com.apple.installer-meta-package
+ com.apple.disk-image-udif
+
+
+
+ CFBundleURLTypes
+
+
+ CFBundleURLName
+ com.github.macadmins.SupportCompanion
+ CFBundleURLSchemes
+
+ supportcompanion
+
+
+
+ CFBundleVersion
+ 3.0.0
+ LSMinimumSystemVersion
+ $(MACOSX_DEPLOYMENT_TARGET)
+ SMPrivilegedExecutables
+
+ com.github.macadmins.SupportCompanion.helper
+ anchor apple generic and identifier "com.github.macadmins.SupportCompanion.helper" and (certificate leaf[field.1.2.840.113635.100.6.1.9] /* exists */ or certificate 1[field.1.2.840.113635.100.6.2.6] /* exists */ and certificate leaf[field.1.2.840.113635.100.6.1.13] /* exists */ and certificate leaf[subject.OU] = $(TEAM_ID))
+
+
+
diff --git a/SupportCompanion/Localizable.xcstrings b/SupportCompanion/Localizable.xcstrings
index 995ab17..e51141f 100644
--- a/SupportCompanion/Localizable.xcstrings
+++ b/SupportCompanion/Localizable.xcstrings
@@ -1,14 +1,41 @@
{
"sourceLanguage" : "en",
"strings" : {
- "" : {
-
- },
"%" : {
+ },
+ "%@ (%lld)" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ (%2$lld)"
+ }
+ }
+ }
+ },
+ "%@ %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@ %2$@"
+ }
+ }
+ }
},
"%@:" : {
+ },
+ "%@: %@" : {
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%1$@: %2$@"
+ }
+ }
+ }
},
"%@%%" : {
@@ -80,6 +107,9 @@
},
"%lld%%" : {
+ },
+ "•" : {
+
},
"°C" : {
@@ -292,6 +322,132 @@
}
}
},
+ "Action.Refetch" : {
+ "comment" : "Button that asks Fleet to re-check this Mac",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erneut prüfen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Re-check"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Revérifier"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再チェック"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sjekk på nytt"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kontrollera igen"
+ }
+ }
+ }
+ },
+ "Action.RefetchHelp" : {
+ "comment" : "Help text for the re-check button",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet auffordern, die Daten dieses Macs zu aktualisieren und die Konformitätsprüfungen erneut auszuführen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Ask Fleet to update this Mac's details and re-run its compliance checks."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demander à Fleet de mettre à jour les informations de ce Mac et de relancer ses vérifications de conformité."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このMacの情報を更新し、コンプライアンスチェックを再実行するようFleetに要求します。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Be Fleet om å oppdatere opplysningene for denne Macen og kjøre samsvarskontrollene på nytt."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Be Fleet att uppdatera informationen om den här Macen och köra efterlevnadskontrollerna igen."
+ }
+ }
+ }
+ },
+ "Action.Refetching" : {
+ "comment" : "Shown while Fleet re-checks this Mac",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird erneut geprüft…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Re-checking…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Revérification…"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再チェック中…"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sjekker på nytt…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kontrollerar igen…"
+ }
+ }
+ }
+ },
"Action.RestartIntuneAgent" : {
"comment" : "Label for restart Intune agent action",
"localizations" : {
@@ -384,6 +540,48 @@
}
}
},
+ "Battery.Calculating" : {
+ "comment" : "Time to full value while macOS is still estimating the charge time",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird berechnet…"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Calculating…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Calcul en cours…"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "計算中…"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beregner…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beräknar…"
+ }
+ }
+ }
+ },
"Battery.Charging" : {
"comment" : "Label for the battery charging status",
"extractionState" : "extracted_with_value",
@@ -468,6 +666,48 @@
}
}
},
+ "Battery.FullyCharged" : {
+ "comment" : "Time to full value when the battery is full on external power",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vollständig geladen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Fully Charged"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Charge complète"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "フル充電"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fulladet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fulladdat"
+ }
+ }
+ }
+ },
"Battery.Health" : {
"comment" : "Label for the battery health",
"extractionState" : "extracted_with_value",
@@ -964,6 +1204,48 @@
}
}
},
+ "Card.FleetPoliciesTitle" : {
+ "comment" : "Title for the card listing failing Fleet policies",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gerätekonformität"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Device Compliance"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conformité de l’appareil"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "デバイスコンプライアンス"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enhetssamsvar"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enhetsefterlevnad"
+ }
+ }
+ }
+ },
"Card.InstalledAppsTitle" : {
"comment" : "Title for installed apps card",
"extractionState" : "extracted_with_value",
@@ -1737,66 +2019,142 @@
}
}
},
- "DeviceInfo.SystemInfo" : {
- "comment" : "Category for system info",
+ "DeviceInfo.SSID" : {
+ "comment" : "Label for SSID",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Systeminformationen"
+ "value" : "SSID:"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "System Information"
+ "value" : "SSID:"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Informations système"
+ "value" : "SSID:"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "システム情報"
+ "value" : "SSID:"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Systeminformasjon"
+ "value" : "SSID:"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Systeminformation"
+ "value" : "SSID:"
}
}
}
},
- "Enter Reason for Elevation" : {
+ "DeviceInfo.SystemInfo" : {
+ "comment" : "Category for system info",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Geben Sie den Grund für die Erhöhung ein"
+ "value" : "Systeminformationen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "System Information"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Entrez la raison de l’élévation"
+ "value" : "Informations système"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "昇格の理由を入力してください"
+ "value" : "システム情報"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Systeminformasjon"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Systeminformation"
+ }
+ }
+ }
+ },
+ "Due by" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bis zum"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Pour le"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "までに"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Innen"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Senast"
+ }
+ }
+ }
+ },
+ "Enter Reason for Elevation" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geben Sie den Grund für die Erhöhung ein"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entrez la raison de l’élévation"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "昇格の理由を入力してください"
}
},
"nb" : {
@@ -1915,2758 +2273,7928 @@
}
}
},
- "GatherLogs.Info" : {
- "comment" : "Info message for gathering logs",
+ "Fleet.AllCategories" : {
+ "comment" : "Category filter showing every app",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Protokollsammlung wurde abgebrochen"
+ "value" : "Alle"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Gather logs was cancelled"
+ "value" : "All"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "La collecte des journaux a été annulée"
+ "value" : "Toutes"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ログ収集がキャンセルされました"
+ "value" : "すべて"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Samling av logger ble avbrutt"
+ "value" : "Alle"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Loggsparning avbruten"
+ "value" : "Alla"
}
}
}
},
- "GatherLogs.Success" : {
- "extractionState" : "manual",
+ "Fleet.AppClosedMessage" : {
+ "comment" : "Card message after the app that blocked an install was closed; %@ is the app name",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Protokolle erfolgreich gesammelt"
+ "value" : "%@ war geöffnet und wurde daher nicht geändert. Versuchen Sie es erneut, jetzt wo die App geschlossen ist."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Successfully gathered logs"
+ "state" : "new",
+ "value" : "%@ was open, so it wasn't changed. Try again now that it's closed."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Journaux collectés avec succès"
+ "value" : "%@ était ouvert, l’app n’a donc pas été modifiée. Réessayez maintenant qu’elle est fermée."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ログ収集が成功しました"
+ "value" : "%@が開いていたため、変更されませんでした。閉じたので、もう一度お試しください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Logger samlet inn med suksess"
+ "value" : "%@ var åpen og ble derfor ikke endret. Prøv igjen nå som appen er lukket."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Loggar sparade"
+ "value" : "%@ var öppen och ändrades därför inte. Försök igen nu när appen är stängd."
}
}
}
},
- "General.Close" : {
- "comment" : "Close",
- "extractionState" : "manual",
+ "Fleet.AppOpenMessage" : {
+ "comment" : "Card message when an install needs the app closed; %@ is the app name",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Schließen"
+ "value" : "%@ ist geöffnet. Beenden Sie die App, um abzuschließen."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Close"
+ "state" : "new",
+ "value" : "%@ is open. Quit it to finish."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Fermer"
+ "value" : "%@ est ouvert. Quittez l’app pour terminer."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "閉じる"
+ "value" : "%@が開いています。終了すると完了します。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Lukk"
+ "value" : "%@ er åpen. Avslutt appen for å fullføre."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Stäng"
+ "value" : "%@ är öppen. Avsluta appen för att slutföra."
}
}
}
},
- "General.Day" : {
- "comment" : "Number of day",
- "extractionState" : "manual",
+ "Fleet.AppOpenNotification" : {
+ "comment" : "Notification when an install needs the app closed; %@ is the app name",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "tag"
+ "value" : "Beenden Sie %@, um die Aktualisierung abzuschließen."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Day"
+ "state" : "new",
+ "value" : "Quit %@ to finish updating it."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "jour"
+ "value" : "Quittez %@ pour terminer la mise à jour."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "日"
+ "value" : "%@を終了すると、アップデートが完了します。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dag"
+ "value" : "Avslutt %@ for å fullføre oppdateringen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dag"
+ "value" : "Avsluta %@ för att slutföra uppdateringen."
}
}
}
},
- "General.DayAgo" : {
- "comment" : "Number of day ago",
+ "Fleet.Available" : {
+ "comment" : "Section of apps that can be installed",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "tag zuvor"
+ "value" : "Verfügbar"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Day Ago"
+ "value" : "Available"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "jour avant"
+ "value" : "Disponibles"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "日前"
+ "value" : "利用可能"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dag siden"
+ "value" : "Tilgjengelige"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dag sedan"
+ "value" : "Tillgängliga"
}
}
}
},
- "General.Days" : {
- "comment" : "Number of days",
- "extractionState" : "manual",
+ "Fleet.Cancel" : {
+ "comment" : "Cancel button",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "tage"
+ "value" : "Abbrechen"
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Days"
+ "state" : "new",
+ "value" : "Cancel"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "jours"
+ "value" : "Annuler"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "日"
+ "value" : "キャンセル"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dager"
+ "value" : "Avbryt"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dagar"
+ "value" : "Avbryt"
}
}
}
},
- "General.DaysAgo" : {
- "comment" : "Number of days ago",
- "extractionState" : "manual",
+ "Fleet.ClearSearch" : {
+ "comment" : "Button that empties the app catalog search field",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "vor tagen"
+ "value" : "Suche löschen"
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Days ago"
+ "state" : "new",
+ "value" : "Clear search"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "jours avant"
+ "value" : "Effacer la recherche"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "日前"
+ "value" : "検索をクリア"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dager siden"
+ "value" : "Tøm søket"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "dagar sedan"
+ "value" : "Rensa sökningen"
}
}
}
},
- "General.Demote" : {
- "comment" : "Demote",
+ "Fleet.Close" : {
+ "comment" : "Button that closes the install details",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Herabstufen"
+ "value" : "Schließen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Demote"
+ "value" : "Close"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Rétrograder"
+ "value" : "Fermer"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "降格する"
+ "value" : "閉じる"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nedgradere"
+ "value" : "Lukk"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nedgradera"
+ "value" : "Stäng"
}
}
}
},
- "General.Elevate" : {
- "comment" : "Elevate",
+ "Fleet.ComplianceDetails" : {
+ "comment" : "Button that opens Support Companion to show compliance details",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Erhöhen"
+ "value" : "Details"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Elevate"
+ "value" : "Details"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Élever"
+ "value" : "Détails"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "昇格"
+ "value" : "詳細"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hev"
+ "value" : "Detaljer"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Elevera"
+ "value" : "Detaljer"
}
}
}
},
- "General.Hour" : {
- "comment" : "Number of hour",
+ "Fleet.CouldNotLoad" : {
+ "comment" : "Title when the Fleet catalog failed to load",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "stunde"
+ "value" : "Apps konnten nicht geladen werden"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Hour"
+ "value" : "Couldn't load apps"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "heure"
+ "value" : "Impossible de charger les apps"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "時間"
+ "value" : "アプリを読み込めませんでした"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "time"
+ "value" : "Kunne ikke laste appene"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "timme"
+ "value" : "Det gick inte att läsa in apparna"
}
}
}
},
- "General.Hours" : {
- "comment" : "Number of hours",
+ "Fleet.CouldNotLoadDetails" : {
+ "comment" : "Shown when install output couldn't be fetched",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "stunden"
+ "value" : "Details konnten nicht geladen werden"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Hours"
+ "value" : "Couldn't load details"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "heures"
+ "value" : "Impossible de charger les détails"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "時間"
+ "value" : "詳細を読み込めませんでした"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "timer"
+ "value" : "Kunne ikke laste detaljene"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "timmar"
+ "value" : "Det gick inte att läsa in detaljerna"
}
}
}
},
- "General.Manage" : {
- "comment" : "Manage",
- "extractionState" : "manual",
+ "Fleet.CouldNotQuit" : {
+ "comment" : "Shown when an app didn't quit; %@ is the app name",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Verwalten"
+ "value" : "%@ wurde nicht beendet. Sichern Sie Ihre Arbeit, beenden Sie die App und versuchen Sie es erneut."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Manage"
+ "state" : "new",
+ "value" : "%@ didn't quit. Save your work, quit it, and try again."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Gérer"
+ "value" : "%@ n’a pas quitté. Enregistrez votre travail, quittez l’app et réessayez."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "管理"
+ "value" : "%@が終了しませんでした。作業を保存してアプリを終了し、もう一度お試しください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Administrer"
+ "value" : "%@ ble ikke avsluttet. Lagre arbeidet ditt, avslutt appen og prøv igjen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hantera"
+ "value" : "%@ avslutades inte. Spara ditt arbete, avsluta appen och försök igen."
}
}
}
},
- "General.Minute" : {
- "comment" : "Number of minute",
+ "Fleet.CouldNotRefresh" : {
+ "comment" : "Shown next to a catalog that failed to refresh",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "minuten"
+ "value" : "Aktualisierung fehlgeschlagen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Minute"
+ "value" : "Couldn't refresh"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible d’actualiser"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "分"
+ "value" : "更新できませんでした"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "minutt"
+ "value" : "Kunne ikke oppdatere"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "minut"
+ "value" : "Det gick inte att uppdatera"
}
}
}
},
- "General.Minutes" : {
- "comment" : "Number of minutes",
+ "Fleet.Critical" : {
+ "comment" : "Badge for a critical Fleet policy",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "minuten"
+ "value" : "Kritisch"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Minutes"
+ "value" : "Critical"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Critique"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "分"
+ "value" : "重要"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "minutter"
+ "value" : "Kritisk"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "minuter"
+ "value" : "Kritisk"
}
}
}
},
- "General.Second" : {
- "comment" : "Number of second",
+ "Fleet.Details" : {
+ "comment" : "Button that shows the output of a failed install or uninstall",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "sekunde"
+ "value" : "Details"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Second"
+ "value" : "Details"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "seconde"
+ "value" : "Détails"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "秒"
+ "value" : "詳細"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "sekund"
+ "value" : "Detaljer"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "sekund"
+ "value" : "Detaljer"
}
}
}
},
- "General.Seconds" : {
- "comment" : "Number of seconds",
+ "Fleet.HowToFix" : {
+ "comment" : "Disclosure that shows how to resolve a failing policy",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "sekunden"
+ "value" : "So beheben Sie das Problem"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Seconds"
+ "value" : "How to fix"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "secondes"
+ "value" : "Comment corriger"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "秒"
+ "value" : "対処方法"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "sekunder"
+ "value" : "Slik løser du det"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "sekunder"
+ "value" : "Så här åtgärdar du det"
}
}
}
},
- "KerberosSSO.ExipiryDays" : {
- "comment" : "Label for the number of days before the password expires",
+ "Fleet.Install" : {
+ "comment" : "Button that installs an app from Fleet",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ablauf des AD-Passworts:"
+ "value" : "Installieren"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "AD Password Expiry:"
+ "value" : "Install"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Expiration du mot de passe AD:"
+ "value" : "Installer"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ADパスワードの有効期限:"
+ "value" : "インストール"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Utløp av AD-passord:"
+ "value" : "Installer"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Utgång av AD-lösenord:"
+ "value" : "Installera"
}
}
}
},
- "KerberosSSO.LastLocalPasswordChangeDays" : {
- "comment" : "Label for the last time the local password was changed",
+ "Fleet.InstallDetailsTitle" : {
+ "comment" : "Title of the sheet showing install output",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Letzte lokale Passwortänderung:"
+ "value" : "Installationsdetails"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Last Local Password Change:"
+ "value" : "Install details"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Dernier changement de mot de passe local:"
+ "value" : "Détails de l’installation"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "最後のローカルパスワード変更:"
+ "value" : "インストールの詳細"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Siste lokale passordendring:"
+ "value" : "Installasjonsdetaljer"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Senaste lokala lösenordsbyte:"
+ "value" : "Installationsdetaljer"
}
}
}
},
- "KerberosSSO.LastSSOPasswordChangeDays" : {
- "comment" : "Label for the last time the SSO password was changed",
+ "Fleet.Installed" : {
+ "comment" : "Section of installed apps, and the installed status badge",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Letzte AD-Passwortänderung:"
+ "value" : "Installiert"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Last AD Password Change:"
+ "value" : "Installed"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Dernier changement de mot de passe AD:"
+ "value" : "Installé"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "最後のADパスワード変更:"
+ "value" : "インストール済み"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Siste AD-passordendring:"
+ "value" : "Installert"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Senaste AD-lösenordsbyte:"
+ "value" : "Installerad"
}
}
}
},
- "KerberosSSO.Username" : {
- "comment" : "Label for the username",
+ "Fleet.InstalledNotification" : {
+ "comment" : "Notification after an install; %@ is the app name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Benutzername:"
+ "value" : "%@ wurde installiert."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Username:"
+ "value" : "%@ was installed."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nom d’utilisateur:"
+ "value" : "%@ a été installé."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ユーザー名:"
+ "value" : "%@をインストールしました。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Brukernavn:"
+ "value" : "%@ ble installert."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Användarnamn:"
+ "value" : "%@ har installerats."
}
}
}
},
- "Loading applications..." : {
+ "Fleet.InstallFailed" : {
+ "comment" : "Status badge when an install failed",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Anwendungen werden geladen…"
+ "value" : "Installation fehlgeschlagen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Install failed"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Chargement des applications…"
+ "value" : "Échec de l’installation"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "アプリケーションを読み込んでいます…"
+ "value" : "インストールに失敗しました"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Laster applikasjoner…"
+ "value" : "Installasjonen mislyktes"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hämtar applikationer…"
+ "value" : "Installationen misslyckades"
}
}
}
},
- "MDM.Enrolled" : {
- "comment" : "Label for the MDM enrollment status",
+ "Fleet.InstallFailedNotification" : {
+ "comment" : "Notification after a failed install or update; %@ is the app name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registriert:"
+ "value" : "%@ konnte nicht installiert werden."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Enrolled:"
+ "value" : "%@ couldn't be installed."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enrôlement:"
+ "value" : "%@ n’a pas pu être installé."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "エンロール:"
+ "value" : "%@をインストールできませんでした。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrert:"
+ "value" : "%@ kunne ikke installeres."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrerad:"
+ "value" : "%@ kunde inte installeras."
}
}
}
},
- "MDM.EnrolledDate" : {
- "comment" : "Label for the MDM enrollment date",
+ "Fleet.Installing" : {
+ "comment" : "Status badge while an app installs",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrierungsdatum:"
+ "value" : "Wird installiert…"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Enrolled Date:"
+ "value" : "Installing…"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Date d'enrôlement:"
+ "value" : "Installation…"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "エンロール日:"
+ "value" : "インストール中…"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registreringsdato:"
+ "value" : "Installerer…"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrerad datum:"
+ "value" : "Installerar…"
}
}
}
},
- "Modal.RebootMessage" : {
- "extractionState" : "manual",
+ "Fleet.LastChecked" : {
+ "comment" : "When compliance last ran; %@ is a relative time such as '5 minutes ago'",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Neustart in 1 Minute"
+ "value" : "Geprüft %@"
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Rebooting in 1 minute"
+ "state" : "new",
+ "value" : "Checked %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ignorer maintenant"
+ "value" : "Vérifié %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "1分後に再起動"
+ "value" : "チェック: %@"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Starter på nytt om 1 minutt"
+ "value" : "Sjekket %@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Omstart sker om 1 minut"
+ "value" : "Kontrollerad %@"
}
}
}
},
- "Modal.RebootTitle" : {
- "extractionState" : "manual",
+ "Fleet.LatestVersion" : {
+ "comment" : "Label for the version available from Fleet",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Neustart geplant"
+ "value" : "Neueste"
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Reboot Scheduled"
+ "state" : "new",
+ "value" : "Latest"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Redémarrage programmé"
+ "value" : "Dernière"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "再起動予定"
+ "value" : "最新"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Planlagt omstart"
+ "value" : "Nyeste"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Omstart schemalagd"
+ "value" : "Senaste"
}
}
}
},
- "Nav.Apps" : {
- "comment" : "Label for apps navigation",
+ "Fleet.Loading" : {
+ "comment" : "Shown while the Fleet catalog loads",
"extractionState" : "extracted_with_value",
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apps werden geladen…"
+ }
+ },
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Apps"
+ "value" : "Loading apps…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chargement des apps…"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "アプリ"
+ "value" : "アプリを読み込み中…"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Apper"
+ "value" : "Laster apper…"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Applikationer"
+ "value" : "Läser in appar…"
}
}
}
},
- "Nav.Home" : {
- "comment" : "Nav label for Home",
- "extractionState" : "manual",
+ "Fleet.MoreActions" : {
+ "comment" : "Help text for the menu with extra actions for an app",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Startseite"
+ "value" : "Weitere Aktionen"
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Home"
+ "state" : "new",
+ "value" : "More actions"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Accueil"
+ "value" : "Plus d’actions"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ホーム"
+ "value" : "その他の操作"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hjem"
+ "value" : "Flere handlinger"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hem"
+ "value" : "Fler åtgärder"
}
}
}
},
- "Nav.Identity" : {
- "comment" : "Label for identity navigation",
- "extractionState" : "manual",
+ "Fleet.NoApps" : {
+ "comment" : "Shown when the Fleet catalog is empty",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Identität"
+ "value" : "Für diesen Mac sind keine Apps verfügbar."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Identity"
+ "state" : "new",
+ "value" : "No apps are available for this Mac."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Identité"
+ "value" : "Aucune app n’est disponible pour ce Mac."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ID"
+ "value" : "このMacで利用できるアプリはありません。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Identitet"
+ "value" : "Ingen apper er tilgjengelige for denne Macen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Identitet"
+ "value" : "Inga appar är tillgängliga för den här Macen."
}
}
}
},
- "Nav.KnowledgeBase" : {
- "extractionState" : "manual",
+ "Fleet.NoMatches" : {
+ "comment" : "Shown when search or category filters hide every app",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Wissensdatenbank"
+ "value" : "Keine Apps entsprechen Ihrer Suche."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Knowledge Base"
+ "state" : "new",
+ "value" : "No apps match your search."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Base de connaissances"
+ "value" : "Aucune app ne correspond à votre recherche."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ナレッジベース"
+ "value" : "検索条件に一致するアプリはありません。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Kunskapsbase"
+ "value" : "Ingen apper samsvarer med søket ditt."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Kunskapsdatabas"
+ "value" : "Inga appar matchar din sökning."
}
}
}
},
- "Nav.SelfService" : {
- "comment" : "Label for self service navigation",
+ "Fleet.NoOutput" : {
+ "comment" : "Shown when a failed install has no output",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selbstbedienung"
+ "value" : "Fleet hat keine Ausgabe gemeldet."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Self Service"
+ "value" : "Fleet didn't report any output."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Libre-service"
+ "value" : "Fleet n’a renvoyé aucune sortie."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "セルフサービス"
+ "value" : "Fleetからの出力はありませんでした。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Selvbetjening"
+ "value" : "Fleet rapporterte ingen utdata."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Självservice"
+ "value" : "Fleet rapporterade inga utdata."
}
}
}
},
- "No" : {
+ "Fleet.NoPolicies" : {
+ "comment" : "Compliance card without policies",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nein"
+ "value" : "Für diesen Mac gibt es keine Konformitätsprüfungen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "There are no compliance checks for this Mac."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Non"
+ "value" : "Il n’y a aucune vérification de conformité pour ce Mac."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "いいえ"
+ "value" : "このMacにはコンプライアンスチェックがありません。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nei"
+ "value" : "Det finnes ingen samsvarskontroller for denne Macen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nej"
+ "value" : "Det finns inga efterlevnadskontroller för den här Macen."
}
}
}
},
- "No installed applications found" : {
+ "Fleet.NotConfigured" : {
+ "comment" : "Shown when orbit or the Fleet server URL is missing",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keine installierten Anwendungen gefunden"
+ "value" : "Fleet ist auf diesem Mac nicht eingerichtet."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Fleet isn't set up on this Mac."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aucune application installée trouvée"
+ "value" : "Fleet n’est pas configuré sur ce Mac."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "インストールされいてるアプリケーションが見つかりません"
+ "value" : "このMacではFleetが設定されていません。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingen installerte applikasjoner funnet"
+ "value" : "Fleet er ikke satt opp på denne Macen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Inga appar hittade"
+ "value" : "Fleet är inte konfigurerat på den här Macen."
}
}
}
},
- "No pending updates" : {
+ "Fleet.PassingChecks" : {
+ "comment" : "Heading above the list of passing Fleet policies; %d is the count",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Keine ausstehenden Updates"
+ "value" : "Bestandene Prüfungen (%d)"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Passing checks (%d)"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aucune mise à jour en attente"
+ "value" : "Vérifications réussies (%d)"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "保留中のアップデートはありません"
+ "value" : "合格したチェック (%d)"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ingen ventende oppdateringer"
+ "value" : "Beståtte kontroller (%d)"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Inga väntande uppdateringar"
+ "value" : "Godkända kontroller (%d)"
}
}
}
},
- "Notification.AppUpdateAvailable" : {
- "comment" : "Notification message when an update is available",
+ "Fleet.PoliciesFailing" : {
+ "comment" : "Compliance card summary; the numbers are failing and total checks",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "App-Updates verfügbar. Bitte aktualisieren Sie Ihre Apps auf die neueste Version."
+ "value" : "%1$d von %2$d Prüfungen erfordern Aufmerksamkeit"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "App Updates Available. Please update your apps to the latest version."
+ "value" : "%1$d of %2$d checks need attention"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mises à jour des applications disponibles. Veuillez mettre à jour vos applications vers la dernière version."
+ "value" : "%1$d vérifications sur %2$d nécessitent votre attention"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "アプリのアップデートが可能です。アプリを最新バージョンにアップデートしてください。"
+ "value" : "%2$d件中%1$d件のチェックに対応が必要です"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "App-oppdateringer tilgjengelig. Oppdater appene til den nyeste versjonen."
+ "value" : "%1$d av %2$d kontroller krever oppmerksomhet"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Appuppdateringar tillgängliga. Uppdatera dina appar till senaste versionen."
+ "value" : "%1$d av %2$d kontroller behöver åtgärdas"
}
}
}
},
- "Notification.ElevationDemoted" : {
- "comment" : "Notification message when the elevation is demoted",
+ "Fleet.PoliciesFailingNotification" : {
+ "comment" : "Notification when several policies start failing; %d is the count, %@ a policy name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ihre erhöhten Berechtigungen wurden herabgestuft."
+ "value" : "Ihr Mac erfordert Aufmerksamkeit: %1$d Prüfungen schlagen fehl, darunter %2$@."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Your elevated privileges have been demoted."
+ "value" : "Your Mac needs attention: %1$d checks are failing, including %2$@."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Vos privilèges élevés ont été rétrogradés."
+ "value" : "Votre Mac nécessite votre attention: %1$d vérifications échouent, dont %2$@."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "あなたの昇格された権限が降格されました。"
+ "value" : "Macに対応が必要です: %1$d件のチェックが失敗しています (%2$@など)。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Dine hevede rettigheter har blitt nedgradert."
+ "value" : "Macen din krever oppmerksomhet: %1$d kontroller mislykkes, blant annet %2$@."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Privilegierade rättigheter borttagna."
+ "value" : "Din Mac behöver åtgärdas: %1$d kontroller misslyckas, däribland %2$@."
}
}
}
},
- "Notification.ElevationHalfway" : {
- "comment" : "Notification message when half the time has passed",
+ "Fleet.PoliciesPassing" : {
+ "comment" : "Compliance card when every policy passes; %d is the number of checks",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ihre erhöhten Berechtigungen werden herabgestuft in"
+ "value" : "Alle %d Prüfungen wurden bestanden."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Your elevated privileges will be demoted in"
+ "value" : "All %d checks are passing."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Vos privilèges élevés seront rétrogradés dans"
+ "value" : "Les %d vérifications sont réussies."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "あなたの昇格された権限は以下で降格されます"
+ "value" : "%d件すべてのチェックに合格しています。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Dine hevede rettigheter vil bli nedgradert om"
+ "value" : "Alle %d kontrollene er bestått."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Priviligerade rättigheter tas bort inom"
+ "value" : "Alla %d kontroller är godkända."
}
}
}
},
- "Notification.ElevationStarted" : {
- "comment" : "Notification message when an elevation is started",
+ "Fleet.PoliciesSignIn" : {
+ "comment" : "Compliance card when Fleet requires single sign-on",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Privilegierte Sitzung gestartet. Sie werden herabgestuft in"
+ "value" : "Melden Sie sich auf der Seite „Apps“ an, um die Konformitätsprüfungen zu sehen."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Privliged session started. You will be demoted in"
+ "value" : "Sign in on the Apps page to see compliance checks."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Session privilégiée démarrée. Vous serez rétrogradé dans"
+ "value" : "Connectez-vous sur la page Apps pour voir les vérifications de conformité."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "特権セッションが開始されました。以下で降格されます"
+ "value" : "コンプライアンスチェックを表示するには、「アプリ」ページでサインインしてください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Privilegert økt startet. Du vil bli nedgradert om"
+ "value" : "Logg inn på Apper-siden for å se samsvarskontrollene."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Privilegierad session startad. Rättigheter tas bort om"
+ "value" : "Logga in på sidan Applikationer för att se efterlevnadskontrollerna."
}
}
}
},
- "Notification.RebootReminder" : {
- "comment" : "Notification message reminding the user to reboot their device",
+ "Fleet.PoliciesUnavailable" : {
+ "comment" : "Compliance card when policies couldn't be loaded",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ihr Gerät wurde zuletzt vor %lld %@ neu gestartet. Bitte starten Sie Ihr Gerät neu, um optimale Leistung und Sicherheit zu gewährleisten."
+ "value" : "Konformitätsprüfungen konnten nicht geladen werden."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Your device was last restarted %lld %@ ago. Please reboot your device to ensure optimal performance and security."
+ "value" : "Couldn't load compliance checks."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Votre appareil a été redémarré pour la dernière fois il y a %lld %@. Veuillez redémarrer votre appareil afin d’assurer des performances et une sécurité optimales."
+ "value" : "Impossible de charger les vérifications de conformité."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "お使いのデバイスは%lld %@前に最後に再起動されました。最適なパフォーマンスとセキュリティを確保するため、デバイスを再起動してください。"
+ "value" : "コンプライアンスチェックを読み込めませんでした。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enheten din ble sist startet på nytt for %lld %@ siden. Start enheten på nytt for å sikre optimal ytelse og sikkerhet."
+ "value" : "Kunne ikke laste samsvarskontrollene."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Din enhet startades om senast för %lld %@ sedan. Starta om enheten för att säkerställa optimal prestanda och säkerhet."
+ "value" : "Det gick inte att läsa in efterlevnadskontrollerna."
}
}
}
},
- "Notification.UpdateAvailable" : {
- "comment" : "Notification message when an update is available",
+ "Fleet.PolicyFailingNotification" : {
+ "comment" : "Notification when a policy starts failing; %@ is the policy name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Software-Updates verfügbar. Bitte aktualisieren Sie Ihr Gerät auf die neueste Version."
+ "value" : "Ihr Mac erfordert Aufmerksamkeit: %@"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Software Updates Available. Please update your device to the latest version."
+ "value" : "Your Mac needs attention: %@"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mises à jour logicielles disponibles. Veuillez mettre à jour votre appareil vers la dernière version."
+ "value" : "Votre Mac nécessite votre attention: %@"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ソフトウェアアップデートが可能です。デバイスを最新バーションにアップデートしてください。"
+ "value" : "Macに対応が必要です: %@"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Programvareoppdateringer tilgjengelig. Oppdater enheten til den nyeste versjonen."
+ "value" : "Macen din krever oppmerksomhet: %@"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "OS-uppdateringar tillgängliga. Uppdatera din enhet till senaste versionen."
+ "value" : "Din Mac behöver åtgärdas: %@"
}
}
}
},
- "Notification.UpdateNow" : {
- "comment" : "Notification button text when an update is available",
+ "Fleet.QuitAndInstall" : {
+ "comment" : "Button that quits an app and installs it again",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Jetzt aktualisieren 🚀"
+ "value" : "Beenden & installieren"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Update Now 🚀"
+ "value" : "Quit & Install"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mettre à jour maintenant 🚀"
+ "value" : "Quitter et installer"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "今すぐアップデート🚀"
+ "value" : "終了してインストール"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Oppdater nå 🚀"
+ "value" : "Avslutt og installer"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Uppdatera nu 🚀"
+ "value" : "Avsluta och installera"
}
}
}
},
- "PlatformSSO.LoginFrequency" : {
- "comment" : "Label for the login frequency",
+ "Fleet.QuitAndUpdate" : {
+ "comment" : "Button that quits an app and installs its update",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Anmeldehäufigkeit:"
+ "value" : "Beenden & aktualisieren"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Login Frequency:"
+ "value" : "Quit & Update"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Fréquence de connexion:"
+ "value" : "Quitter et mettre à jour"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ログイン頻度:"
+ "value" : "終了してアップデート"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Påloggingsfrekvens:"
+ "value" : "Avslutt og oppdater"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Inloggningsfrekvens:"
+ "value" : "Avsluta och uppdatera"
}
}
}
},
- "PlatformSSO.LoginType" : {
- "comment" : "Label for the login type",
+ "Fleet.Recommended" : {
+ "comment" : "Section of apps IT recommends installing",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Anmeldetyp:"
+ "value" : "Empfohlen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Login Type:"
+ "value" : "Recommended"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Type de connexion:"
+ "value" : "Recommandées"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ログインタイプ:"
+ "value" : "おすすめ"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Påloggingstype:"
+ "value" : "Anbefalte"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Inloggningstyp:"
+ "value" : "Rekommenderade"
}
}
}
},
- "PlatformSSO.NewUserAuthorizationMode" : {
- "comment" : "Label for the new user authorization mode",
+ "Fleet.Reinstall" : {
+ "comment" : "Button that installs an installed app again through Fleet",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Neuer Benutzer-Autorisierungsmodus:"
+ "value" : "Neu installieren"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "New User Authorization Mode:"
+ "value" : "Reinstall"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nouveau mode d’autorisation utilisateur"
+ "value" : "Réinstaller"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "新規ユーザー認証モード:"
+ "value" : "再インストール"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ny brukergodkjenningsmodus:"
+ "value" : "Installer på nytt"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ny användarbehörighetsläge:"
+ "value" : "Installera om"
}
}
}
},
- "PlatformSSO.RegistrationCompleted" : {
- "comment" : "Label for the registration completion status",
+ "Fleet.ReinstalledNotification" : {
+ "comment" : "Notification after a reinstall; %@ is the app name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrierung abgeschlossen:"
+ "value" : "%@ wurde neu installiert."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Registration Completed:"
+ "value" : "%@ was reinstalled."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Enregistrement terminé"
+ "value" : "%@ a été réinstallé."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "登録状態:"
+ "value" : "%@を再インストールしました。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrering fullført:"
+ "value" : "%@ ble installert på nytt."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Registrering slutförd:"
+ "value" : "%@ har installerats om."
}
}
}
},
- "PlatformSSO.SDKVersionString" : {
- "comment" : "Label for the SDK version string",
+ "Fleet.Retry" : {
+ "comment" : "Button to reload the Fleet catalog",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "SDK-Version:"
+ "value" : "Erneut versuchen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "SDK Version:"
+ "value" : "Try Again"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Version SDK:"
+ "value" : "Réessayer"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "SDKバージョン:"
+ "value" : "再試行"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "SDK-versjon:"
+ "value" : "Prøv igjen"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "SDK-version"
+ "value" : "Försök igen"
}
}
}
},
- "PlatformSSO.SharedDeviceKeys" : {
- "comment" : "Label for the shared device keys",
+ "Fleet.SearchPlaceholder" : {
+ "comment" : "Placeholder for the Fleet app catalog search field",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Gemeinsame Geräteschlüssel:"
+ "value" : "Apps suchen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Shared Device Keys:"
+ "value" : "Search apps"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Clés d’appareils partagées:"
+ "value" : "Rechercher des apps"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "共有デバイスキー:"
+ "value" : "アプリを検索"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Delte enhetsnøkler:"
+ "value" : "Søk i apper"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Delade enhetsnycklar:"
+ "value" : "Sök appar"
}
}
}
},
- "PlatformSSO.UserAuthorizationMode" : {
- "comment" : "Label for the user authorization mode",
+ "Fleet.SignedOutFailing" : {
+ "comment" : "Compliance summary while signed out; %d is the number of failing checks",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Benutzer-Autorisierungsmodus:"
+ "value" : "%d Prüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "User Authorization Mode:"
+ "value" : "%d checks need attention. Sign in to see which."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Mode d’autorisation utilisateur:"
+ "value" : "%d vérifications nécessitent votre attention. Connectez-vous pour voir lesquelles."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ユーザー認証モード:"
+ "value" : "%d件のチェックに対応が必要です。サインインして確認してください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Brukergodkjenningsmodus:"
+ "value" : "%d kontroller krever oppmerksomhet. Logg inn for å se hvilke."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Användarbehörighetsläge"
+ "value" : "%d kontroller behöver åtgärdas. Logga in för att se vilka."
}
}
}
},
- "Please provide a reason for elevating your privileges. This will be logged for auditing purposes." : {
+ "Fleet.SignedOutPassing" : {
+ "comment" : "Compliance summary while signed out when nothing is failing",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Bitte geben Sie einen Grund für die Erhöhung Ihrer Berechtigungen an. Dies wird zu Prüfzwecken protokolliert."
+ "value" : "Keine Prüfungen sind fehlgeschlagen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "No checks are failing."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Veuillez fournir une raison pour élever vos privilèges. Cela sera enregistré à des fins d’audit."
+ "value" : "Aucune vérification n’est en échec."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "権限を昇格させる理由を提供してください。これは監査目的で記録されます。"
+ "value" : "失敗しているチェックはありません。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Vennligst oppgi en grunn for å heve dine rettigheter. Dette vil bli loggført for revisjonsformål."
+ "value" : "Ingen kontroller er mislykket."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ange en anledning till att du behöver höja dina behörigheter. Detta kommer att loggas för revisionsändamål."
+ "value" : "Inga kontroller misslyckas."
}
}
}
},
- "Realm:" : {
+ "Fleet.SignedOutRecord" : {
+ "comment" : "Fleet info card when the user hasn't signed in",
+ "extractionState" : "extracted_with_value",
"localizations" : {
- "ja" : {
+ "de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "レルム:"
+ "value" : "Melden Sie sich an, um den Eintrag dieses Mac in Fleet zu sehen."
}
- }
- }
- },
- "Rebooting in %lld seconds..." : {
- "localizations" : {
- "de" : {
+ },
+ "en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Neustart in %lld Sekunden…"
+ "state" : "new",
+ "value" : "Sign in to see this Mac's record in Fleet."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Redémarrage dans %lld secondes…"
+ "value" : "Connectez-vous pour voir la fiche de ce Mac dans Fleet."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "%lld秒後に再起動…"
+ "value" : "このMacのFleetでの登録情報を表示するには、サインインしてください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Omstart om %lld sekunder…"
+ "value" : "Logg inn for å se oppføringen for denne Macen i Fleet."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Startar om inom %lld seconds..."
+ "value" : "Logga in för att se den här Macens post i Fleet."
}
}
}
},
- "Revocation in: " : {
+ "Fleet.SignIn" : {
+ "comment" : "Button that starts Fleet single sign-on",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Widerruf in:"
+ "value" : "Anmelden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Sign In"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Révocation dans:"
+ "value" : "Se connecter"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "取り消しまで:"
+ "value" : "サインイン"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Tilbakekalling om:"
+ "value" : "Logg inn"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Revokeras inom:"
- }
- }
- }
- },
- "Rings" : {
- "localizations" : {
- "ja" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "リング"
+ "value" : "Logga in"
}
}
}
},
- "Select an option" : {
+ "Fleet.SignInForChecks" : {
+ "comment" : "Compliance page when Fleet requires single sign-on",
+ "extractionState" : "extracted_with_value",
"localizations" : {
- "ja" : {
+ "de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "オプションの選択"
+ "value" : "Melden Sie sich an, um die Konformitätsprüfungen zu sehen."
}
- }
- }
- },
- "Storage.Name" : {
- "comment" : "Label for the storage name",
- "extractionState" : "extracted_with_value",
- "localizations" : {
+ },
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Name:"
+ "value" : "Sign in to see compliance checks."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nom:"
+ "value" : "Connectez-vous pour voir les vérifications de conformité."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "名前:"
+ "value" : "コンプライアンスチェックを表示するには、サインインしてください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Navn:"
+ "value" : "Logg inn for å se samsvarskontrollene."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Namn:"
+ "value" : "Logga in för att se efterlevnadskontrollerna."
}
}
}
},
- "Support Information" : {
+ "Fleet.SignInNotification" : {
+ "comment" : "Notification when Fleet needs the user to sign in",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Supportinformationen"
+ "value" : "Melden Sie sich bei Fleet an, um Ihre Apps und Konformitätsprüfungen zu sehen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Sign in to Fleet to see your apps and compliance checks."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Informations de support"
+ "value" : "Connectez-vous à Fleet pour voir vos apps et vos vérifications de conformité."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "サポート情報"
+ "value" : "アプリとコンプライアンスチェックを表示するには、Fleetにサインインしてください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Støtteinformasjon"
+ "value" : "Logg inn på Fleet for å se appene og samsvarskontrollene dine."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Supportinformation"
+ "value" : "Logga in på Fleet för att se dina appar och efterlevnadskontroller."
}
}
}
},
- "Support.Email" : {
- "comment" : "Email address",
+ "Fleet.SignInNotificationFailing" : {
+ "comment" : "Notification when a signed-out Mac has failing checks; %d is how many",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-Mail:"
+ "value" : "%d Konformitätsprüfungen erfordern Aufmerksamkeit. Melden Sie sich an, um zu sehen, welche."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Email:"
+ "value" : "%d compliance checks need attention. Sign in to see which."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d vérifications de conformité nécessitent votre attention. Connectez-vous pour voir lesquelles."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Eメール:"
+ "value" : "%d件のコンプライアンスチェックに対応が必要です。サインインして確認してください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-post:"
+ "value" : "%d samsvarskontroller krever oppmerksomhet. Logg inn for å se hvilke."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "E-post:"
+ "value" : "%d efterlevnadskontroller behöver åtgärdas. Logga in för att se vilka."
}
}
}
},
- "Support.Phone" : {
- "comment" : "Phone number",
+ "Fleet.SignInRequired" : {
+ "comment" : "Shown when Fleet requires single sign-on",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Telefon:"
+ "value" : "Melden Sie sich an, um die für diesen Mac verfügbaren Apps zu sehen."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Phone:"
+ "value" : "Sign in to see the apps available for this Mac."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Téléphone:"
+ "value" : "Connectez-vous pour voir les apps disponibles pour ce Mac."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "電話:"
+ "value" : "このMacで利用できるアプリを表示するには、サインインしてください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Telefon:"
+ "value" : "Logg inn for å se appene som er tilgjengelige for denne Macen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Telefon:"
+ "value" : "Logga in för att se apparna som är tillgängliga för den här Macen."
}
}
}
},
- "Support.Support" : {
- "comment" : "Support title",
+ "Fleet.SSOConnecting" : {
+ "comment" : "Shown while the Fleet sign-in page is being prepared",
"extractionState" : "extracted_with_value",
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verbindung zum Identitätsanbieter wird hergestellt…"
+ }
+ },
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Support"
+ "value" : "Connecting to your identity provider…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connexion à votre fournisseur d’identité…"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "サポート"
+ "value" : "IDプロバイダに接続中…"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kobler til identitetsleverandøren…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ansluter till din identitetsleverantör…"
}
}
}
},
- "TabelHeaders.Action" : {
- "comment" : "Header for the action column",
+ "Fleet.SSOCouldNotSignIn" : {
+ "comment" : "Title when Fleet single sign-on failed",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Aktion"
+ "value" : "Anmeldung nicht möglich"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Action"
+ "value" : "Couldn't sign in"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connexion impossible"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "アクション"
+ "value" : "サインインできませんでした"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Handling"
+ "value" : "Kunne ikke logge inn"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Åtgärd"
+ "value" : "Det gick inte att logga in"
}
}
}
},
- "TabelHeaders.Name" : {
- "comment" : "Header for the name column",
+ "Fleet.SSODisabled" : {
+ "comment" : "Shown when Fleet Desktop single sign-on was turned off during sign-in",
"extractionState" : "extracted_with_value",
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Single Sign-On für Fleet ist nicht aktiviert. Wenden Sie sich an Ihre IT-Abteilung."
+ }
+ },
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Name"
+ "value" : "Single sign-on for Fleet isn't enabled. Contact your IT department."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nom"
+ "value" : "L’authentification unique pour Fleet n’est pas activée. Contactez votre service informatique."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "名前"
+ "value" : "Fleetのシングルサインオンが有効になっていません。IT部門にお問い合わせください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Navn"
+ "value" : "Enkel pålogging for Fleet er ikke aktivert. Kontakt IT-avdelingen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Namn"
+ "value" : "Enkel inloggning för Fleet är inte aktiverad. Kontakta din IT-avdelning."
}
}
}
},
- "TabelHeaders.Version" : {
- "comment" : "Header for the version column",
+ "Fleet.SSOFailed" : {
+ "comment" : "Shown when the identity provider didn't return a valid Fleet session",
"extractionState" : "extracted_with_value",
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihr Identitätsanbieter hat die Anmeldung nicht abgeschlossen. Versuchen Sie es erneut."
+ }
+ },
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Version"
+ "value" : "Your identity provider didn't complete the sign-in. Try again."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre fournisseur d’identité n’a pas terminé la connexion. Réessayez."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "バージョン"
+ "value" : "IDプロバイダがサインインを完了しませんでした。もう一度お試しください。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Versjon"
+ "value" : "Identitetsleverandøren fullførte ikke påloggingen. Prøv igjen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Version"
+ "value" : "Din identitetsleverantör slutförde inte inloggningen. Försök igen."
}
}
}
},
- "Title.SaveLogs" : {
- "comment" : "Title for save logs dialog",
+ "Fleet.SSOSheetTitle" : {
+ "comment" : "Title of the Fleet single sign-on window",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Protokolle speichern"
+ "value" : "Bei Fleet anmelden"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Save Logs"
+ "value" : "Sign in to Fleet"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Sauvegarder les journaux"
+ "value" : "Se connecter à Fleet"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ログの保存"
+ "value" : "Fleetにサインイン"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Lagre logger"
+ "value" : "Logg inn på Fleet"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Spara loggar"
+ "value" : "Logga in på Fleet"
}
}
}
},
- "ToolTip.DeviceInfoCopy" : {
- "comment" : "Tooltip text when copying device information to clipboard",
+ "Fleet.Uninstall" : {
+ "comment" : "Button that uninstalls an app through Fleet",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Geräteinformationen in die Zwischenablage kopieren"
+ "value" : "Deinstallieren"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Copy device information to clipboard"
+ "value" : "Uninstall"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Copier les informations de l’appareil dans le presse-papiers"
+ "value" : "Désinstaller"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "クリップボードにデバイス情報をコピー"
+ "value" : "アンインストール"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Kopier enhetsinformasjon til utklippstavlen"
+ "value" : "Avinstaller"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Kopiera enhetsinformation"
+ "value" : "Avinstallera"
}
}
}
},
- "ToolTip.DeviceLastRebooted" : {
- "comment" : "Tooltip text when showing reboot information",
- "extractionState" : "manual",
+ "Fleet.UninstallConfirmMessage" : {
+ "comment" : "Message asking to confirm an uninstall",
+ "extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Regelmäßige Neustarts können die Leistung und Langlebigkeit verbessern, indem temporäre Dateien entfernt und Systemressourcen freigegeben werden."
+ "value" : "Die App wird von diesem Mac entfernt."
}
},
"en" : {
"stringUnit" : {
- "state" : "translated",
- "value" : "Regularly rebooting your device can enhance its performance and longevity by clearing temporary files and freeing up system resources."
+ "state" : "new",
+ "value" : "The app will be removed from this Mac."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Un redémarrage régulier de votre appareil peut améliorer ses performances et sa durée de vie en nettoyant les fichiers temporaires et en libérant les ressources système."
+ "value" : "L’app sera supprimée de ce Mac."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "定期的にデバイスを再起動することで、一時ファイルをクリアし、システムリソースが解放され、デバイスのパフォーマンスと寿命を向上させることができます。"
+ "value" : "このMacからアプリが削除されます。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Å restarte maskinen jevnlig kan forbedre ytelse og levetid ved å slette midlertidige filer og frigjøre systemressurser."
+ "value" : "Appen blir fjernet fra denne Macen."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Att regelbundet starta om din enhet kan förbättra dess prestanda och livslängd genom att rensa tillfälliga filer och frigöra systemresurser."
+ "value" : "Appen tas bort från den här Macen."
}
}
}
},
- "ToolTip.OpenStoragePanel" : {
- "comment" : "Tooltip text when opening the storage panel",
+ "Fleet.UninstallConfirmTitle" : {
+ "comment" : "Title asking to confirm an uninstall; %@ is the app name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Speicherfenster öffnen"
+ "value" : "%@ deinstallieren?"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Open storage panel"
+ "value" : "Uninstall %@?"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ouvrir le panneau de stockage"
+ "value" : "Désinstaller %@?"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ストレージパネルを開く"
+ "value" : "%@をアンインストールしますか?"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Åpne lagringspanel"
+ "value" : "Vil du avinstallere %@?"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Öppna lagringsvy"
+ "value" : "Vill du avinstallera %@?"
}
}
}
},
- "TrayMenu.OpenApp" : {
- "comment" : "Open the app",
+ "Fleet.UninstallDetailsTitle" : {
+ "comment" : "Title of the sheet showing uninstall output",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Support Companion öffnen"
+ "value" : "Deinstallationsdetails"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Open Support Companion"
+ "value" : "Uninstall details"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ouvrir Support Companion"
+ "value" : "Détails de la désinstallation"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Support Companionを開く"
+ "value" : "アンインストールの詳細"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Åpne Support Companion"
+ "value" : "Avinstallasjonsdetaljer"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Öppna Support Companion"
+ "value" : "Avinstallationsdetaljer"
}
}
}
},
- "TrayMenu.QuitApp" : {
- "comment" : "Quit the app",
+ "Fleet.UninstalledNotification" : {
+ "comment" : "Notification after an uninstall; %@ is the app name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Beenden"
+ "value" : "%@ wurde deinstalliert."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Quit"
+ "value" : "%@ was uninstalled."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Quitter"
+ "value" : "%@ a été désinstallé."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "終了"
+ "value" : "%@をアンインストールしました。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Avslutt"
+ "value" : "%@ ble avinstallert."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Avsluta"
- }
- }
- }
- },
- "Type:" : {
- "localizations" : {
- "ja" : {
- "stringUnit" : {
- "state" : "translated",
- "value" : "タイプ:"
+ "value" : "%@ har avinstallerats."
}
}
}
},
- "UserInfo.HomeDir" : {
- "comment" : "Label for the home directory",
+ "Fleet.UninstallFailed" : {
+ "comment" : "Status badge when an uninstall failed",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Benutzerverzeichnis:"
+ "value" : "Deinstallation fehlgeschlagen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Home Directory:"
+ "value" : "Uninstall failed"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Répertoire personnel:"
+ "value" : "Échec de la désinstallation"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ホームディレクトリ:"
+ "value" : "アンインストールに失敗しました"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hjemmekatalog:"
+ "value" : "Avinstallasjonen mislyktes"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Hemkatalog:"
+ "value" : "Avinstallationen misslyckades"
}
}
}
},
- "UserInfo.IsAdmin" : {
- "comment" : "Label for the is admin status",
+ "Fleet.UninstallFailedNotification" : {
+ "comment" : "Notification after a failed uninstall; %@ is the app name",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Ist Administrator:"
+ "value" : "%@ konnte nicht deinstalliert werden."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Is Admin:"
+ "value" : "%@ couldn't be uninstalled."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Est administrateur:"
+ "value" : "%@ n’a pas pu être désinstallé."
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "管理者:"
+ "value" : "%@をアンインストールできませんでした。"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Er administrator:"
+ "value" : "%@ kunne ikke avinstalleres."
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Är admin:"
+ "value" : "%@ kunde inte avinstalleras."
}
}
}
},
- "UserInfo.Name" : {
- "comment" : "Label for the name",
+ "Fleet.Uninstalling" : {
+ "comment" : "Status badge while an app uninstalls",
"extractionState" : "extracted_with_value",
"localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird deinstalliert…"
+ }
+ },
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Name:"
+ "value" : "Uninstalling…"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nom:"
+ "value" : "Désinstallation…"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "名前:"
+ "value" : "アンインストール中…"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Navn:"
+ "value" : "Avinstallerer…"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Namn:"
+ "value" : "Avinstallerar…"
}
}
}
},
- "UserInfo.Shell" : {
+ "Fleet.Update" : {
+ "comment" : "Button that updates an app from Fleet",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aktualisieren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Update"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre à jour"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アップデート"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oppdater"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uppdatera"
+ }
+ }
+ }
+ },
+ "Fleet.UpdateAvailable" : {
+ "comment" : "Status badge when a newer version is available",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Update verfügbar"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Update available"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mise à jour disponible"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アップデートあり"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oppdatering tilgjengelig"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uppdatering tillgänglig"
+ }
+ }
+ }
+ },
+ "Fleet.UpdatedNotification" : {
+ "comment" : "Notification after an update; %@ is the app name",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ wurde aktualisiert."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "%@ was updated."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ a été mis à jour."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@をアップデートしました。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ ble oppdatert."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%@ har uppdaterats."
+ }
+ }
+ }
+ },
+ "Fleet.UpdatesAvailable" : {
+ "comment" : "Section of apps with a newer version available",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verfügbare Updates"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Updates Available"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mises à jour disponibles"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "利用可能なアップデート"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tilgjengelige oppdateringer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tillgängliga uppdateringar"
+ }
+ }
+ }
+ },
+ "Fleet.Version" : {
+ "comment" : "Label for an app's installed version",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Version"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バージョン"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Versjon"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ }
+ }
+ },
+ "Fleet.ViewApps" : {
+ "comment" : "Button that shows the Apps page in Support Companion",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apps anzeigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "View Apps"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voir les apps"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリを表示"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vis apper"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visa appar"
+ }
+ }
+ }
+ },
+ "Fleet.ViewDetails" : {
+ "comment" : "Notification button that opens Support Companion",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Details anzeigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "View Details"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voir les détails"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "詳細を表示"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vis detaljer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visa detaljer"
+ }
+ }
+ }
+ },
+ "Fleet.ViewUpdates" : {
+ "comment" : "Button that shows available app updates in Support Companion",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Updates anzeigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "View Updates"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Voir les mises à jour"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アップデートを表示"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vis oppdateringer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visa uppdateringar"
+ }
+ }
+ }
+ },
+ "Fleet.WaitingForAppToClose" : {
+ "comment" : "Status badge when an install didn't run because the app was open",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Warten auf das Beenden der App"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Waiting for app to close"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "En attente de la fermeture de l’app"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリの終了待ち"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Venter på at appen lukkes"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Väntar på att appen stängs"
+ }
+ }
+ }
+ },
+ "FleetInfo.ID" : {
+ "comment" : "Label for the Fleet host ID",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Host-ID:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Host ID:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID d’hôte:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ホストID:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Host-ID:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Host-ID:"
+ }
+ }
+ }
+ },
+ "FleetInfo.LastInventory" : {
+ "comment" : "Label for when Fleet last updated this Mac's details",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventar:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Inventory:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventaire:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インベントリ:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventar:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventering:"
+ }
+ }
+ }
+ },
+ "FleetInfo.LastSeen" : {
+ "comment" : "Label for when Fleet last heard from this Mac",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kontakt:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Check-In:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Connexion:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "チェックイン:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Innsjekk:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Incheckning:"
+ }
+ }
+ }
+ },
+ "FleetInfo.Never" : {
+ "comment" : "Shown when Fleet hasn't recorded an event yet",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nie"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Never"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jamais"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "なし"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aldri"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aldrig"
+ }
+ }
+ }
+ },
+ "FleetInfo.NoTeam" : {
+ "comment" : "Shown when the Mac isn't in a fleet",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kein Fleet"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "No fleet"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucun fleet"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "所属なし"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingen fleet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingen fleet"
+ }
+ }
+ }
+ },
+ "FleetInfo.RefetchFailed" : {
+ "comment" : "Shown when a re-check request failed",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet konnte nicht aufgefordert werden, diesen Mac erneut zu prüfen."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Couldn't ask Fleet to re-check this Mac."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Impossible de demander à Fleet de revérifier ce Mac."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このMacの再チェックをFleetに要求できませんでした。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kunne ikke be Fleet om å sjekke denne Macen på nytt."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Det gick inte att be Fleet kontrollera den här Macen igen."
+ }
+ }
+ }
+ },
+ "FleetInfo.Team" : {
+ "comment" : "Label for the fleet name",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Fleet:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fleet:"
+ }
+ }
+ }
+ },
+ "FleetInfo.Unknown" : {
+ "comment" : "Shown before Fleet details load",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unbekannt"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Unknown"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inconnu"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "不明"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ukjent"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Okänd"
+ }
+ }
+ }
+ },
+ "FleetInfo.URL" : {
+ "comment" : "Label for the Fleet server",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "URL:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ }
+ }
+ },
+ "GatherLogs.Info" : {
+ "comment" : "Info message for gathering logs",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Protokollsammlung wurde abgebrochen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Gather logs was cancelled"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "La collecte des journaux a été annulée"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ログ収集がキャンセルされました"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Samling av logger ble avbrutt"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Loggsparning avbruten"
+ }
+ }
+ }
+ },
+ "GatherLogs.Success" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Protokolle erfolgreich gesammelt"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Successfully gathered logs"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Journaux collectés avec succès"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ログ収集が成功しました"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Logger samlet inn med suksess"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Loggar sparade"
+ }
+ }
+ }
+ },
+ "General.Ago" : {
+ "comment" : "Ago",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tagen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Ago"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "avant"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "前"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "siden"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sedan"
+ }
+ }
+ }
+ },
+ "General.Cancel" : {
+ "comment" : "Cancel",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Abbrechen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cancel"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Annuler"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "キャンセル"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avbryt"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avbryt"
+ }
+ }
+ }
+ },
+ "General.Close" : {
+ "comment" : "Close",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Schließen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Close"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fermer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "閉じる"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lukk"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Stäng"
+ }
+ }
+ }
+ },
+ "General.Day" : {
+ "comment" : "Number of day",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tag"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Day"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jour"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "日"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dag"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dag"
+ }
+ }
+ }
+ },
+ "General.DayAgo" : {
+ "comment" : "Number of day ago",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tag zuvor"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Day Ago"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jour avant"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "日前"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dag siden"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dag sedan"
+ }
+ }
+ }
+ },
+ "General.Days" : {
+ "comment" : "Number of days",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "tage"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Days"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jours"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "日"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dager"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dagar"
+ }
+ }
+ }
+ },
+ "General.DaysAgo" : {
+ "comment" : "Number of days ago",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "vor tagen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Days ago"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "jours avant"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "日前"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dager siden"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "dagar sedan"
+ }
+ }
+ }
+ },
+ "General.Demote" : {
+ "comment" : "Demote",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Herabstufen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Demote"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rétrograder"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "降格する"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nedgradere"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nedgradera"
+ }
+ }
+ }
+ },
+ "General.Done" : {
+ "comment" : "Done",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fertig"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Done"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Terminé"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "完了"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ferdig"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Klar"
+ }
+ }
+ }
+ },
+ "General.Elevate" : {
+ "comment" : "Elevate",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erhöhen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Elevate"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Élever"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "昇格"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hev"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Elevera"
+ }
+ }
+ }
+ },
+ "General.Hour" : {
+ "comment" : "Number of hour",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "stunde"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Hour"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heure"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "時間"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "time"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timme"
+ }
+ }
+ }
+ },
+ "General.Hours" : {
+ "comment" : "Number of hours",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "stunden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Hours"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "heures"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "時間"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "timmar"
+ }
+ }
+ }
+ },
+ "General.JustNow" : {
+ "comment" : "Just Now",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gerade eben"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Just Now"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "À l’instant"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "たった今"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Akkurat nå"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nyss"
+ }
+ }
+ }
+ },
+ "General.Manage" : {
+ "comment" : "Manage",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verwalten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Manage"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gérer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "管理"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Administrer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hantera"
+ }
+ }
+ }
+ },
+ "General.Minute" : {
+ "comment" : "Number of minute",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "minuten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Minute"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minute"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "minutt"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "minut"
+ }
+ }
+ }
+ },
+ "General.Minutes" : {
+ "comment" : "Number of minutes",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "minuten"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Minutes"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Minutes"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "分"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "minutter"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "minuter"
+ }
+ }
+ }
+ },
+ "General.Second" : {
+ "comment" : "Number of second",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sekunde"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Second"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "seconde"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sekund"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sekund"
+ }
+ }
+ }
+ },
+ "General.Seconds" : {
+ "comment" : "Number of seconds",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sekunden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Seconds"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "secondes"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "秒"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sekunder"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "sekunder"
+ }
+ }
+ }
+ },
+ "JamfInfo.ID" : {
+ "comment" : "Label for the ID",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "ID:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID:"
+ }
+ }
+ }
+ },
+ "JamfInfo.LastCheckin" : {
+ "comment" : "Label for the last check-in date",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Check-in:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Check-In:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Check-in:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "チェックイン:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Check-in:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "needs_review",
+ "value" : "Check-in:"
+ }
+ }
+ }
+ },
+ "JamfInfo.LastInventory" : {
+ "comment" : "Label for the last update date",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventur:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Inventory:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventaire:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インベントリ:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventar:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inventering:"
+ }
+ }
+ }
+ },
+ "JamfInfo.URL" : {
+ "comment" : "Label for the URL",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "URL:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "URL:"
+ }
+ }
+ }
+ },
+ "KerberosSSO.ExipiryDays" : {
+ "comment" : "Label for the number of days before the password expires",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ablauf des AD-Passworts:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "AD Password Expiry:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Expiration du mot de passe AD:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ADパスワードの有効期限:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utløp av AD-passord:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utgång av AD-lösenord:"
+ }
+ }
+ }
+ },
+ "KerberosSSO.LastLocalPasswordChangeDays" : {
+ "comment" : "Label for the last time the local password was changed",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Letzte lokale Passwortänderung:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Last Local Password Change:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dernier changement de mot de passe local:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "最後のローカルパスワード変更:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Siste lokale passordendring:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Senaste lokala lösenordsbyte:"
+ }
+ }
+ }
+ },
+ "KerberosSSO.LastSSOPasswordChangeDays" : {
+ "comment" : "Label for the last time the SSO password was changed",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Letzte AD-Passwortänderung:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Last AD Password Change:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dernier changement de mot de passe AD:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "最後のADパスワード変更:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Siste AD-passordendring:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Senaste AD-lösenordsbyte:"
+ }
+ }
+ }
+ },
+ "KerberosSSO.Username" : {
+ "comment" : "Label for the username",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzername:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Username:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom d’utilisateur:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Brukernavn:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Användarnamn:"
+ }
+ }
+ }
+ },
+ "Loading applications..." : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anwendungen werden geladen…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Chargement des applications…"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリケーションを読み込んでいます…"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Laster applikasjoner…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hämtar applikationer…"
+ }
+ }
+ }
+ },
+ "MDM.Enrolled" : {
+ "comment" : "Label for the MDM enrollment status",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registriert:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Enrolled:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enrôlement:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "エンロール:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrert:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrerad:"
+ }
+ }
+ }
+ },
+ "MDM.EnrolledDate" : {
+ "comment" : "Label for the MDM enrollment date",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrierungsdatum:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Enrolled Date:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Date d'enrôlement:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "エンロール日:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registreringsdato:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrerad datum:"
+ }
+ }
+ }
+ },
+ "Modal.RebootMessage" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neustart in 1 Minute"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Rebooting in 1 minute"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ignorer maintenant"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "1分後に再起動"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Starter på nytt om 1 minutt"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Omstart sker om 1 minut"
+ }
+ }
+ }
+ },
+ "Modal.RebootTitle" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neustart geplant"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Reboot Scheduled"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Redémarrage programmé"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "再起動予定"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Planlagt omstart"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Omstart schemalagd"
+ }
+ }
+ }
+ },
+ "Nav.Apps" : {
+ "comment" : "Label for apps navigation",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Apps"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリ"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Apper"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Applikationer"
+ }
+ }
+ }
+ },
+ "Nav.CompanyPortal" : {
+ "comment" : "Label for company portal navigation",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Unternehmensportal"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Company Portal"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Portail d’entreprise"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "会社ポータル"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Firmaportal"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Företagsportal"
+ }
+ }
+ }
+ },
+ "Nav.Compliance" : {
+ "comment" : "Label for compliance navigation",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Konformität"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Compliance"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Conformité"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "コンプライアンス"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Samsvar"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Efterlevnad"
+ }
+ }
+ }
+ },
+ "Nav.Home" : {
+ "comment" : "Nav label for Home",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Startseite"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Home"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Accueil"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ホーム"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hjem"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hem"
+ }
+ }
+ }
+ },
+ "Nav.Identity" : {
+ "comment" : "Label for identity navigation",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identität"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identity"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identité"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ID"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identitet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identitet"
+ }
+ }
+ }
+ },
+ "Nav.KnowledgeBase" : {
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wissensdatenbank"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Knowledge Base"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Base de connaissances"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ナレッジベース"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kunskapsbase"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kunskapsdatabas"
+ }
+ }
+ }
+ },
+ "Nav.SelfService" : {
+ "comment" : "Label for self service navigation",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Selbstbedienung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Self Service"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Libre-service"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "セルフサービス"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Selvbetjening"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Självservice"
+ }
+ }
+ }
+ },
+ "No" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nein"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Non"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "いいえ"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nei"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nej"
+ }
+ }
+ }
+ },
+ "No installed applications found" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Keine installierten Anwendungen gefunden"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune application installée trouvée"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストールされいてるアプリケーションが見つかりません"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingen installerte applikasjoner funnet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inga appar hittade"
+ }
+ }
+ }
+ },
+ "No pending updates" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Keine ausstehenden Updates"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aucune mise à jour en attente"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "保留中のアップデートはありません"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ingen ventende oppdateringer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inga väntande uppdateringar"
+ }
+ }
+ }
+ },
+ "Notification.AppUpdateAvailable" : {
+ "comment" : "Notification message when an update is available",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App-Updates verfügbar. Bitte aktualisieren Sie Ihre Apps auf die neueste Version."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "App Updates Available. Please update your apps to the latest version."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mises à jour des applications disponibles. Veuillez mettre à jour vos applications vers la dernière version."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリのアップデートが可能です。アプリを最新バージョンにアップデートしてください。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "App-oppdateringer tilgjengelig. Oppdater appene til den nyeste versjonen."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Appuppdateringar tillgängliga. Uppdatera dina appar till senaste versionen."
+ }
+ }
+ }
+ },
+ "Notification.ElevationDemoted" : {
+ "comment" : "Notification message when the elevation is demoted",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihre erhöhten Berechtigungen wurden herabgestuft."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your elevated privileges have been demoted."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vos privilèges élevés ont été rétrogradés."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "あなたの昇格された権限が降格されました。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dine hevede rettigheter har blitt nedgradert."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Privilegierade rättigheter borttagna."
+ }
+ }
+ }
+ },
+ "Notification.ElevationHalfway" : {
+ "comment" : "Notification message when half the time has passed",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihre erhöhten Berechtigungen werden herabgestuft in"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your elevated privileges will be demoted in"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vos privilèges élevés seront rétrogradés dans"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "あなたの昇格された権限は以下で降格されます"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dine hevede rettigheter vil bli nedgradert om"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Priviligerade rättigheter tas bort inom"
+ }
+ }
+ }
+ },
+ "Notification.ElevationStarted" : {
+ "comment" : "Notification message when an elevation is started",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Privilegierte Sitzung gestartet. Sie werden herabgestuft in"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Privileged session started. You will be demoted in"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Session privilégiée démarrée. Vous serez rétrogradé dans"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "特権セッションが開始されました。以下で降格されます"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Privilegert økt startet. Du vil bli nedgradert om"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Privilegierad session startad. Rättigheter tas bort om"
+ }
+ }
+ }
+ },
+ "Notification.RebootReminder" : {
+ "comment" : "Notification message reminding the user to reboot their device",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihr Gerät wurde zuletzt vor %1$lld %2$@ neu gestartet. Bitte starten Sie Ihr Gerät neu, um optimale Leistung und Sicherheit zu gewährleisten."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your device was last restarted %1$lld %2$@ ago. Please reboot your device to ensure optimal performance and security."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre appareil a été redémarré pour la dernière fois il y a %1$lld %2$@. Veuillez redémarrer votre appareil afin d’assurer des performances et une sécurité optimales."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "お使いのデバイスは%1$lld %2$@前に最後に再起動されました。最適なパフォーマンスとセキュリティを確保するため、デバイスを再起動してください。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enheten din ble sist startet på nytt for %1$lld %2$@ siden. Start enheten på nytt for å sikre optimal ytelse og sikkerhet."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Din enhet startades om senast för %1$lld %2$@ sedan. Starta om enheten för att säkerställa optimal prestanda och säkerhet."
+ }
+ }
+ }
+ },
+ "Notification.UpdateAvailable" : {
+ "comment" : "Notification message when an update is available",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Software-Updates verfügbar. Bitte aktualisieren Sie Ihr Gerät auf die neueste Version."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Software Updates Available. Please update your device to the latest version."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mises à jour logicielles disponibles. Veuillez mettre à jour votre appareil vers la dernière version."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ソフトウェアアップデートが可能です。デバイスを最新バーションにアップデートしてください。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Programvareoppdateringer tilgjengelig. Oppdater enheten til den nyeste versjonen."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "OS-uppdateringar tillgängliga. Uppdatera din enhet till senaste versionen."
+ }
+ }
+ }
+ },
+ "Notification.UpdateNow" : {
+ "comment" : "Notification button text when an update is available",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Jetzt aktualisieren 🚀"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Update Now 🚀"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mettre à jour maintenant 🚀"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "今すぐアップデート🚀"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Oppdater nå 🚀"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Uppdatera nu 🚀"
+ }
+ }
+ }
+ },
+ "PlatformSSO.LoginFrequency" : {
+ "comment" : "Label for the login frequency",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anmeldehäufigkeit:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Login Frequency:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Fréquence de connexion:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ログイン頻度:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Påloggingsfrekvens:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inloggningsfrekvens:"
+ }
+ }
+ }
+ },
+ "PlatformSSO.LoginType" : {
+ "comment" : "Label for the login type",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anmeldetyp:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Login Type:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Type de connexion:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ログインタイプ:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Påloggingstype:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Inloggningstyp:"
+ }
+ }
+ }
+ },
+ "PlatformSSO.NewUserAuthorizationMode" : {
+ "comment" : "Label for the new user authorization mode",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neuer Benutzer-Autorisierungsmodus:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "New User Authorization Mode:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nouveau mode d’autorisation utilisateur"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "新規ユーザー認証モード:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ny brukergodkjenningsmodus:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ny användarbehörighetsläge:"
+ }
+ }
+ }
+ },
+ "PlatformSSO.RegistrationCompleted" : {
+ "comment" : "Label for the registration completion status",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrierung abgeschlossen:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Registration Completed:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Enregistrement terminé"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "登録状態:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrering fullført:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Registrering slutförd:"
+ }
+ }
+ }
+ },
+ "PlatformSSO.SDKVersionString" : {
+ "comment" : "Label for the SDK version string",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SDK-Version:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "SDK Version:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version SDK:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SDKバージョン:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SDK-versjon:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SDK-version"
+ }
+ }
+ }
+ },
+ "PlatformSSO.SharedDeviceKeys" : {
+ "comment" : "Label for the shared device keys",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Gemeinsame Geräteschlüssel:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Shared Device Keys:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Clés d’appareils partagées:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "共有デバイスキー:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Delte enhetsnøkler:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Delade enhetsnycklar:"
+ }
+ }
+ }
+ },
+ "PlatformSSO.UserAuthorizationMode" : {
+ "comment" : "Label for the user authorization mode",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzer-Autorisierungsmodus:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "User Authorization Mode:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Mode d’autorisation utilisateur:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー認証モード:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Brukergodkjenningsmodus:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Användarbehörighetsläge"
+ }
+ }
+ }
+ },
+ "Please provide a reason for elevating your privileges. This will be logged for auditing purposes." : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Bitte geben Sie einen Grund für die Erhöhung Ihrer Berechtigungen an. Dies wird zu Prüfzwecken protokolliert."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Veuillez fournir une raison pour élever vos privilèges. Cela sera enregistré à des fins d’audit."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "権限を昇格させる理由を提供してください。これは監査目的で記録されます。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vennligst oppgi en grunn for å heve dine rettigheter. Dette vil bli loggført for revisjonsformål."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ange en anledning till att du behöver höja dina behörigheter. Detta kommer att loggas för revisionsändamål."
+ }
+ }
+ }
+ },
+ "Realm:" : {
+ "localizations" : {
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "レルム:"
+ }
+ }
+ }
+ },
+ "Rebooting in %lld seconds..." : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Neustart in %lld Sekunden…"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Redémarrage dans %lld secondes…"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%lld秒後に再起動…"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Omstart om %lld sekunder…"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Startar om inom %lld sekunder…"
+ }
+ }
+ }
+ },
+ "Revocation in: " : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Widerruf in:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Révocation dans:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "取り消しまで:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tilbakekalling om:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Revokeras inom:"
+ }
+ }
+ }
+ },
+ "Rings" : {
+ "localizations" : {
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "リング"
+ }
+ }
+ }
+ },
+ "Select an option" : {
+ "localizations" : {
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "オプションの選択"
+ }
+ }
+ }
+ },
+ "Storage.Name" : {
+ "comment" : "Label for the storage name",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Name:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "名前:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Navn:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Namn:"
+ }
+ }
+ }
+ },
+ "Support Information" : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supportinformationen"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Informations de support"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "サポート情報"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Støtteinformasjon"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Supportinformation"
+ }
+ }
+ }
+ },
+ "Support.Email" : {
+ "comment" : "Email address",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-Mail:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Email:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eメール:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-post:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "E-post:"
+ }
+ }
+ }
+ },
+ "Support.Phone" : {
+ "comment" : "Phone number",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefon:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Phone:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Téléphone:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "電話:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefon:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Telefon:"
+ }
+ }
+ }
+ },
+ "Support.Support" : {
+ "comment" : "Support title",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Support"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "サポート"
+ }
+ }
+ }
+ },
+ "TableHeaders.Action" : {
+ "comment" : "Header for the action column",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Aktion"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Action"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Action"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アクション"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Handling"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Åtgärd"
+ }
+ }
+ }
+ },
+ "TableHeaders.Name" : {
+ "comment" : "Header for the name column",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Name"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Name"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "名前"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Navn"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Namn"
+ }
+ }
+ }
+ },
+ "TableHeaders.Version" : {
+ "comment" : "Header for the version column",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Version"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バージョン"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Versjon"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ }
+ }
+ },
+ "This list shows applications installed by %@." : {
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Diese Liste zeigt die von %@ installierten Apps an."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Cette liste affiche les applications installées par %@."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "この一覧には、%@ がインストールしたアプリケーションが表示されます。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Denne listen viser apper som er installert av %@."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Den här listan visar applikationer installerade av %@."
+ }
+ }
+ }
+ },
+ "Title.SaveLogs" : {
+ "comment" : "Title for save logs dialog",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Protokolle speichern"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Save Logs"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Sauvegarder les journaux"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ログの保存"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Lagre logger"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Spara loggar"
+ }
+ }
+ }
+ },
+ "ToolTip.DeviceInfoCopy" : {
+ "comment" : "Tooltip text when copying device information to clipboard",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Geräteinformationen in die Zwischenablage kopieren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Copy device information to clipboard"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copier les informations de l’appareil dans le presse-papiers"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "クリップボードにデバイス情報をコピー"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kopier enhetsinformasjon til utklippstavlen"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kopiera enhetsinformation"
+ }
+ }
+ }
+ },
+ "ToolTip.DeviceLastRebooted" : {
+ "comment" : "Tooltip text when showing reboot information",
+ "extractionState" : "manual",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Regelmäßige Neustarts können die Leistung und Langlebigkeit verbessern, indem temporäre Dateien entfernt und Systemressourcen freigegeben werden."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Regularly rebooting your device can enhance its performance and longevity by clearing temporary files and freeing up system resources."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Un redémarrage régulier de votre appareil peut améliorer ses performances et sa durée de vie en nettoyant les fichiers temporaires et en libérant les ressources système."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "定期的にデバイスを再起動することで、一時ファイルをクリアし、システムリソースが解放され、デバイスのパフォーマンスと寿命を向上させることができます。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Å restarte maskinen jevnlig kan forbedre ytelse og levetid ved å slette midlertidige filer og frigjøre systemressurser."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Att regelbundet starta om din enhet kan förbättra dess prestanda och livslängd genom att rensa tillfälliga filer och frigöra systemresurser."
+ }
+ }
+ }
+ },
+ "ToolTip.OpenStoragePanel" : {
+ "comment" : "Tooltip text when opening the storage panel",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Speicherfenster öffnen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Open storage panel"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ouvrir le panneau de stockage"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ストレージパネルを開く"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Åpne lagringspanel"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Öppna lagringsvy"
+ }
+ }
+ }
+ },
+ "TrayMenu.OpenApp" : {
+ "comment" : "Open the app",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Support Companion öffnen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Open Support Companion"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ouvrir Support Companion"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Support Companionを開く"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Åpne Support Companion"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Öppna Support Companion"
+ }
+ }
+ }
+ },
+ "TrayMenu.QuitApp" : {
+ "comment" : "Quit the app",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Beenden"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Quit"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Quitter"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "終了"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avslutt"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Avsluta"
+ }
+ }
+ }
+ },
+ "Type:" : {
+ "localizations" : {
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "タイプ:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Typ:"
+ }
+ }
+ }
+ },
+ "UserInfo.HomeDir" : {
+ "comment" : "Label for the home directory",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzerverzeichnis:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Home Directory:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Répertoire personnel:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ホームディレクトリ:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hjemmekatalog:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Hemkatalog:"
+ }
+ }
+ }
+ },
+ "UserInfo.IsAdmin" : {
+ "comment" : "Label for the is admin status",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ist Administrator:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Is Admin:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Est administrateur:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "管理者:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Er administrator:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Är admin:"
+ }
+ }
+ }
+ },
+ "UserInfo.Name" : {
+ "comment" : "Label for the name",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Name:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "名前:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Navn:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Namn:"
+ }
+ }
+ }
+ },
+ "UserInfo.Shell" : {
"comment" : "Label for the shell",
"extractionState" : "extracted_with_value",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Shell:"
+ "value" : "Shell:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "シェル:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skall:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Skal:"
+ }
+ }
+ }
+ },
+ "UserInfo.Username" : {
+ "comment" : "Label for the username",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Benutzername:"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Username:"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Nom d’utilisateur:"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "ユーザー名:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Brukernavn:"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Användarnamn:"
+ }
+ }
+ }
+ },
+ "UserInstalls.AllowedAs" : {
+ "comment" : "Label for which allowlist entry matched",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Zugelassen als"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Allowed as"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Autorisé en tant que"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "許可の種類"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tillatt som"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Tillåten som"
+ }
+ }
+ }
+ },
+ "UserInstalls.AllowedByAdministrator" : {
+ "comment" : "Shown when an installer matches the administrator's allowlist",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihr Administrator erlaubt Ihnen, dies zu installieren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your administrator allows you to install this"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre administrateur vous autorise à installer ceci"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "管理者がこのインストールを許可しています"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Administratoren din tillater at du installerer dette"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Din administratör tillåter att du installerar detta"
+ }
+ }
+ }
+ },
+ "UserInstalls.AvailableInCatalog" : {
+ "comment" : "Shown when a refused installer matches something in the software catalog",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihre Organisation bietet dies bereits an"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your organisation already offers this"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre organisation propose déjà ceci"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "組織がすでにこれを提供しています"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Organisasjonen din tilbyr allerede dette"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Din organisation erbjuder redan detta"
+ }
+ }
+ }
+ },
+ "UserInstalls.AvailableInCatalogDetail" : {
+ "comment" : "Explains the catalog suggestion; %@ is the name of the Apps page",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installieren Sie die genehmigte Version aus %@ anstelle dieses Downloads."
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Install the approved version from %@ instead of this download."
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installez la version approuvée depuis %@ au lieu de ce téléchargement."
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このダウンロードではなく、%@ から承認済みのバージョンをインストールしてください。"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installer den godkjente versjonen fra %@ i stedet for denne nedlastingen."
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installera den godkända versionen från %@ i stället för den här nedladdningen."
+ }
+ }
+ }
+ },
+ "UserInstalls.Certificate" : {
+ "comment" : "Label for the digest of the certificate an installer was signed with",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 des Signaturzertifikats"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Signing certificate SHA-256"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 du certificat de signature"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "署名証明書のSHA-256"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 for signeringssertifikatet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 för signeringscertifikatet"
+ }
+ }
+ }
+ },
+ "UserInstalls.Checking" : {
+ "comment" : "Shown while the helper assesses an installer the user opened",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Dieses Installationsprogramm wird geprüft"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Checking this installer"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérification de cet installateur"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "このインストーラを確認しています"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kontrollerer dette installasjonsprogrammet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kontrollerar det här installationsprogrammet"
+ }
+ }
+ }
+ },
+ "UserInstalls.Details" : {
+ "comment" : "Collapsed section holding what an organisation needs to allow an installer",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Details"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Details"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Détails"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "詳細"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Detaljer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Detaljer"
+ }
+ }
+ }
+ },
+ "UserInstalls.Developer" : {
+ "comment" : "Label for who signed the installer",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entwickler"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Developer"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Développeur"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "開発元"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utvikler"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utvecklare"
+ }
+ }
+ }
+ },
+ "UserInstalls.ElevateInstead" : {
+ "comment" : "Button offering the elevation flow for an installer that is not allowed",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Administratorrechte anfordern"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Request admin rights"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Demander des droits d’administrateur"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "管理者権限を要求"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Be om administratorrettigheter"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Begär administratörsrättigheter"
+ }
+ }
+ }
+ },
+ "UserInstalls.Failed" : {
+ "comment" : "Shown when an allowed installer failed to install",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Die Installation wurde nicht abgeschlossen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "The installation did not finish"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "L’installation ne s’est pas terminée"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストールが完了しませんでした"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installasjonen ble ikke fullført"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installationen slutfördes inte"
+ }
+ }
+ }
+ },
+ "UserInstalls.Fingerprint" : {
+ "comment" : "Precedes a file name, above that file's digest, for a refused installer",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 von"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "SHA-256 of"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 de"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256:"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 av"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "SHA-256 för"
+ }
+ }
+ }
+ },
+ "UserInstalls.Identifier" : {
+ "comment" : "Label for a refused installer's package or bundle identifier",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Kennung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Identifier"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identifiant"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "識別子"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identifikator"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Identifierare"
+ }
+ }
+ }
+ },
+ "UserInstalls.Install" : {
+ "comment" : "Button that installs an allowed application",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installieren"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Install"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installer"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストール"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installera"
+ }
+ }
+ }
+ },
+ "UserInstalls.Installed" : {
+ "comment" : "Shown when an allowed installer has finished installing",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Erfolgreich installiert"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Installed successfully"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installé avec succès"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストールが完了しました"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installasjonen er fullført"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installationen lyckades"
+ }
+ }
+ }
+ },
+ "UserInstalls.Installing" : {
+ "comment" : "Shown while an allowed installer is being installed",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Wird installiert"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Installing"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installation en cours"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストール中"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installerer"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installerar"
+ }
+ }
+ }
+ },
+ "UserInstalls.InstallsTo" : {
+ "comment" : "Label for the places an installer writes",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installiert nach"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Installs to"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installe dans"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストール先"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installerer til"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installerar till"
+ }
+ }
+ }
+ },
+ "UserInstalls.KindApplication" : {
+ "comment" : "Describes an app from a .dmg in the install window",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Programm"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Application"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Application"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "アプリケーション"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Program"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Program"
+ }
+ }
+ }
+ },
+ "UserInstalls.KindPackage" : {
+ "comment" : "Describes a .pkg in the install window",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installationspaket"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Installer package"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Paquet d’installation"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "インストーラパッケージ"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installasjonspakke"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Installationspaket"
+ }
+ }
+ }
+ },
+ "UserInstalls.NotAllowed" : {
+ "comment" : "Shown when an installer does not match the organisation's allowlist",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Ihre Organisation hat dieses Installationsprogramm nicht zugelassen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Your organisation has not allowed this installer"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Votre organisation n’a pas autorisé cet installateur"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "シェル:"
+ "value" : "組織はこのインストーラを許可していません"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Organisasjonen din har ikke tillatt dette installasjonsprogrammet"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Din organisation har inte tillåtit det här installationsprogrammet"
}
}
}
},
- "UserInfo.Username" : {
- "comment" : "Label for the username",
+ "UserInstalls.OpenInInstaller" : {
+ "comment" : "Button handing the installer to Installer.app after the check could not be made",
"extractionState" : "extracted_with_value",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Benutzername:"
+ "value" : "Trotzdem öffnen"
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Username:"
+ "value" : "Open Anyway"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Nom d’utilisateur:"
+ "value" : "Ouvrir quand même"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
- "value" : "ユーザー名:"
+ "value" : "それでも開く"
}
},
"nb" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Brukernavn:"
+ "value" : "Åpne likevel"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
- "value" : "Användarnamn:"
+ "value" : "Öppna ändå"
+ }
+ }
+ }
+ },
+ "UserInstalls.ShowInCatalog" : {
+ "comment" : "Button taking the user to the software catalog page",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Anzeigen"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Show me"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Afficher"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "表示"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vis meg"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Visa"
+ }
+ }
+ }
+ },
+ "UserInstalls.Verification" : {
+ "comment" : "Label for how the installer was verified",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifiziert"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Verified"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Vérifié"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "検証"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifisert"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Verifierad"
+ }
+ }
+ }
+ },
+ "UserInstalls.VerifiedByDigest" : {
+ "comment" : "Shown when an installer matched on its SHA-256",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Exakte Kopie, die Ihre Organisation genehmigt hat"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Exact copy your organisation approved"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Copie exacte approuvée par votre organisation"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "組織が承認したものと完全に一致"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Eksakt kopi som organisasjonen din har godkjent"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Exakt kopia som din organisation har godkänt"
+ }
+ }
+ }
+ },
+ "UserInstalls.VerifiedNotarized" : {
+ "comment" : "Shown when an installer matched on a notarized signature",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Signatur und Apple-Notarisierung"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Signature and Apple notarization"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Signature et notarisation Apple"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "署名とAppleの公証"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Signatur og Apple-notarisering"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Signatur och Apple-notarisering"
+ }
+ }
+ }
+ },
+ "UserInstalls.VerifiedSignature" : {
+ "comment" : "Shown when an installer matched on its signature alone",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Entwicklersignatur"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Developer signature"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Signature du développeur"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "開発元の署名"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utviklersignatur"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Utvecklarsignatur"
+ }
+ }
+ }
+ },
+ "UserInstalls.Version" : {
+ "comment" : "Precedes the version number of an installer in the install window",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "de" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Version"
+ }
+ },
+ "fr" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
+ }
+ },
+ "ja" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "バージョン"
+ }
+ },
+ "nb" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Versjon"
+ }
+ },
+ "sv" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "Version"
}
}
}
diff --git a/SupportCompanion/Models/Action.swift b/SupportCompanion/Models/Action.swift
index 3a196cc..72ed9a6 100644
--- a/SupportCompanion/Models/Action.swift
+++ b/SupportCompanion/Models/Action.swift
@@ -15,4 +15,9 @@ struct Action: Identifiable, Equatable, Hashable {
let isPrivileged: Bool?
let description: String?
let buttonLabel: String?
+
+ /// Everything except the id, for detecting whether a reloaded action list actually changed
+ var contentKey: [String] {
+ [name, command, icon ?? "", isPrivileged.map { String($0) } ?? "", description ?? "", buttonLabel ?? ""]
+ }
}
diff --git a/SupportCompanion/Models/Battery.swift b/SupportCompanion/Models/Battery.swift
index 79a3a9d..1bd8fa7 100644
--- a/SupportCompanion/Models/Battery.swift
+++ b/SupportCompanion/Models/Battery.swift
@@ -14,10 +14,12 @@ struct BatteryInfo: Identifiable {
let maxCapacity: Int
let cycleCount: Int
let isCharging: String
- let temperature: Double
+ /// Nil when the battery doesn't report a temperature.
+ let temperature: Double?
let timeToFull: String
var tempColor: Color {
+ guard let temperature else { return .primary }
if temperature > 60 {
return Color(NSColor.red)
} else if temperature > 40 {
@@ -38,7 +40,7 @@ struct BatteryInfo: Identifiable {
func toKeyValuePairs() -> [(key: String, display: String, value: InfoValue)] {
let health = healthPercentage
- return [
+ return withoutMissingTemperature([
(
key: Constants.Battery.Keys.health,
display: Constants.Battery.Labels.health,
@@ -52,7 +54,7 @@ struct BatteryInfo: Identifiable {
(
key: Constants.Battery.Keys.temperature,
display: Constants.Battery.Labels.temperature,
- value: .double(temperature)
+ value: .double(temperature ?? 0)
),
(
key: Constants.Battery.Keys.isCharging,
@@ -64,13 +66,13 @@ struct BatteryInfo: Identifiable {
display: Constants.Battery.Labels.timeToFull,
value: .string(timeToFull)
)
- ]
+ ])
}
func toKeyValuePairsCompact() -> [(key: String, display: String, value: InfoValue)] {
let health = healthPercentage
- return [
+ return withoutMissingTemperature([
(
key: Constants.Battery.Keys.health,
display: Constants.Battery.Labels.health,
@@ -79,13 +81,17 @@ struct BatteryInfo: Identifiable {
(
key: Constants.Battery.Keys.temperature,
display: Constants.Battery.Labels.temperature,
- value: .double(temperature)
+ value: .double(temperature ?? 0)
),
(
key: Constants.Battery.Keys.timeToFull,
display: Constants.Battery.Labels.timeToFull,
value: .string(timeToFull)
)
- ]
+ ])
+ }
+
+ private func withoutMissingTemperature(_ rows: [(key: String, display: String, value: InfoValue)]) -> [(key: String, display: String, value: InfoValue)] {
+ temperature == nil ? rows.filter { $0.key != Constants.Battery.Keys.temperature } : rows
}
}
diff --git a/SupportCompanion/Models/CatalogSuggestion.swift b/SupportCompanion/Models/CatalogSuggestion.swift
new file mode 100644
index 0000000..b16b86f
--- /dev/null
+++ b/SupportCompanion/Models/CatalogSuggestion.swift
@@ -0,0 +1,76 @@
+//
+// CatalogSuggestion.swift
+// SupportCompanion
+//
+
+import Foundation
+
+/// One thing an organisation's software catalog offers, reduced to what matching needs.
+///
+/// Each mode has its own model — a Fleet title, a Munki optional install, a Jamf policy — and each
+/// maps it to this. The matching rule then lives in one place instead of once per mode.
+struct CatalogEntry {
+ let name: String
+ let bundleIdentifier: String?
+}
+
+/// A catalog entry that looks like the installer somebody just opened, and where to send them for it.
+struct CatalogSuggestion: Equatable {
+ /// What the catalog calls it.
+ let name: String
+
+ /// Where the approved copy lives, from the mode's own `managementApp(forUpdates:)` — the Apps
+ /// page for Fleet, Managed Software Center for Munki, and so on.
+ let destinationName: String
+ let destinationPath: String
+}
+
+/// Decides whether a catalog offers what an installer contains.
+///
+/// Deliberately strict. Sending somebody to install the wrong application is worse than saying
+/// nothing at all, so a near miss is treated as a miss.
+enum CatalogMatching {
+
+ static func match(_ facts: InstallerFacts, in entries: [CatalogEntry]) -> CatalogEntry? {
+ guard !entries.isEmpty else { return nil }
+
+ // A bundle identifier is the only exact signal, so it decides on its own.
+ if let exact = entries.first(where: { entry in
+ guard let bundleIdentifier = entry.bundleIdentifier, !bundleIdentifier.isEmpty else { return false }
+ return facts.identifiers.contains(bundleIdentifier)
+ }) {
+ return exact
+ }
+
+ // Otherwise the name, with version numbers and punctuation removed from both sides, and only
+ // on equality: "Firefox 156.0.dmg" is Firefox, while "Firefox" is not "Firefox Developer".
+ let wanted = Set(
+ [facts.displayName, (facts.fileName as NSString).deletingPathExtension]
+ .compactMap { $0 }
+ .map(comparable)
+ .filter { !$0.isEmpty }
+ )
+
+ guard !wanted.isEmpty else { return nil }
+
+ return entries.first { wanted.contains(comparable($0.name)) }
+ }
+
+ /// A name with its version dropped, so "Slack_V4.40.128" and "Slack" are the same thing.
+ ///
+ /// Version *tokens* go rather than every digit: an installer is as likely to be called
+ /// "1Password" as "Firefox 156.0", and throwing away all digits would reduce the first to
+ /// "password" and match the wrong thing — or nothing.
+ static func comparable(_ name: String) -> String {
+ name
+ .split(whereSeparator: { !$0.isLetter && !$0.isNumber })
+ .filter { !isVersion(String($0)) }
+ .joined()
+ .lowercased()
+ }
+
+ /// Whether a token is only a version: "156", "4", "v2" — but not "1Password" or "3T".
+ private static func isVersion(_ token: String) -> Bool {
+ token.range(of: #"^[vV]?\d+$"#, options: .regularExpression) != nil
+ }
+}
diff --git a/SupportCompanion/Models/DeviceInfo.swift b/SupportCompanion/Models/DeviceInfo.swift
index e97f144..acaa375 100644
--- a/SupportCompanion/Models/DeviceInfo.swift
+++ b/SupportCompanion/Models/DeviceInfo.swift
@@ -7,7 +7,7 @@
import Foundation
-struct DeviceInfo: Identifiable, Equatable {
+struct DeviceInfo: Identifiable {
let id: UUID
let hostName: String
let osVersion: String
@@ -15,15 +15,12 @@ struct DeviceInfo: Identifiable, Equatable {
let cpuType: String
let ram: String
var ipAddress: String
+ var ssid: String?
let serialNumber: String
var lastRestart: Int
var lastRestartDays: Int
let model: String
- static func == (lhs: DeviceInfo, rhs: DeviceInfo) -> Bool {
- return lhs.id == rhs.id
- }
-
func toKeyValuePairs() -> [(key: String, display: String, value: InfoValue, category: String)] {
return [
// Hardware Specifications
@@ -78,7 +75,7 @@ struct DeviceInfo: Identifiable, Equatable {
category: Constants.DeviceInfo.Categories.systemInfo
),
(
- key: "lastRestartDays",
+ key: Constants.DeviceInfo.Keys.lastRestartDays,
display: Constants.DeviceInfo.Labels.lastRestart,
value: .int(lastRestartDays),
category: Constants.DeviceInfo.Categories.systemInfo
@@ -90,6 +87,12 @@ struct DeviceInfo: Identifiable, Equatable {
display: Constants.DeviceInfo.Labels.ipAddress,
value: .string(ipAddress),
category: Constants.DeviceInfo.Categories.networkInfo
+ ),
+ (
+ key: Constants.DeviceInfo.Keys.ssid,
+ display: Constants.DeviceInfo.Labels.ssid,
+ value: .string(ssid ?? "Unknown"),
+ category: Constants.DeviceInfo.Categories.networkInfo
)
]
}
diff --git a/SupportCompanion/Models/InfoHelp.swift b/SupportCompanion/Models/InfoHelp.swift
new file mode 100644
index 0000000..b60178b
--- /dev/null
+++ b/SupportCompanion/Models/InfoHelp.swift
@@ -0,0 +1,43 @@
+//
+// InfoHelp.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-10-14.
+//
+
+import Foundation
+import SwiftUI
+
+struct InfoHelp: View {
+ let text: String
+ let icon: String?
+ let color: Color?
+
+ @State private var hoveringRaw = false // raw hover state
+ @State private var hoveringPopover = false // delayed popover state
+
+ var body: some View {
+ Image(systemName: icon ?? "info.circle")
+ .frame(width: 24, height: 24)
+ .contentShape(Rectangle())
+ .foregroundColor(color)
+ .onHover { inside in
+ hoveringRaw = inside
+
+ if inside {
+ // Delay before opening popover
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
+ if hoveringRaw { // still hovering
+ hoveringPopover = true
+ }
+ }
+ } else {
+ hoveringPopover = false // close immediately
+ }
+ }
+ .popover(isPresented: $hoveringPopover, arrowEdge: .top) {
+ Text(text.isEmpty ? "Details missing" : text)
+ .padding()
+ }
+ }
+}
diff --git a/SupportCompanion/Models/InstalledApp.swift b/SupportCompanion/Models/InstalledApp.swift
index 51dca65..16ad7fc 100644
--- a/SupportCompanion/Models/InstalledApp.swift
+++ b/SupportCompanion/Models/InstalledApp.swift
@@ -17,4 +17,8 @@ struct InstalledApp: Identifiable {
let path: String
let type: String
let bundleId: String
+ let iconUrl: String?
+ let actionText: String?
+ /// Local icon file resolved when the list is built. Nil falls back to the default symbol, or to downloading `iconUrl`.
+ var iconPath: String? = nil
}
diff --git a/SupportCompanion/Models/Jamf.swift b/SupportCompanion/Models/Jamf.swift
new file mode 100644
index 0000000..45ce72c
--- /dev/null
+++ b/SupportCompanion/Models/Jamf.swift
@@ -0,0 +1,40 @@
+//
+// Jamf.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-11-12.
+//
+
+import Foundation
+
+struct JamfInfo: Codable {
+ let lastCheckIn: String
+ let lastInventory: String
+ let url: String
+ let jamfID: String
+
+ func toKeyValuePairs() -> [(key: String, display: String, value: InfoValue)] {
+ return [
+ (
+ key: Constants.JamfInfo.Keys.id,
+ display: Constants.JamfInfo.Labels.id,
+ value: .string(jamfID)
+ ),
+ (
+ key: Constants.JamfInfo.Keys.lastCheckin,
+ display: Constants.JamfInfo.Labels.lastCheckin,
+ value: .string(lastCheckIn)
+ ),
+ (
+ key: Constants.JamfInfo.Keys.lastInventory,
+ display: Constants.JamfInfo.Labels.lastInventory,
+ value: .string(lastInventory)
+ ),
+ (
+ key: Constants.JamfInfo.Keys.url,
+ display: Constants.JamfInfo.Labels.url,
+ value: .string(url)
+ )
+ ]
+ }
+}
diff --git a/SupportCompanion/Models/PendingFleetUpdate.swift b/SupportCompanion/Models/PendingFleetUpdate.swift
new file mode 100644
index 0000000..34a4b4a
--- /dev/null
+++ b/SupportCompanion/Models/PendingFleetUpdate.swift
@@ -0,0 +1,31 @@
+//
+// PendingFleetUpdate.swift
+// SupportCompanion
+//
+
+import Foundation
+
+/// A Fleet self-service title with a newer version available.
+struct PendingFleetUpdate: PendingUpdate, Equatable {
+ let titleID: Int
+ let name: String
+ let installedVersion: String
+ let availableVersion: String
+
+ /// Stable per title, so lists don't redraw every row on each refresh.
+ var id: UUID {
+ UUID(uuidString: String(format: "00000000-0000-0000-0000-%012d", titleID % 1_000_000_000_000)) ?? UUID()
+ }
+
+ var version: String { "\(installedVersion) → \(availableVersion)" }
+
+ init?(title: FleetSoftwareTitle) {
+ guard title.isUpdateAvailable,
+ let installed = title.installedVersion,
+ let available = title.availableVersion else { return nil }
+ titleID = title.id
+ name = title.title
+ installedVersion = installed
+ availableVersion = available
+ }
+}
diff --git a/SupportCompanion/Models/PendingJamfUpdate.swift b/SupportCompanion/Models/PendingJamfUpdate.swift
new file mode 100644
index 0000000..006e9d6
--- /dev/null
+++ b/SupportCompanion/Models/PendingJamfUpdate.swift
@@ -0,0 +1,45 @@
+//
+// PendingJamfUpdate.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-10-13.
+//
+
+import Foundation
+
+struct PendingJamfUpdate: Identifiable, Equatable {
+ let id: UUID
+ let name: String
+ let version: String
+ let needsUpdate: Bool
+ let label: UpdateLabel
+ let details: String
+ let showInfoIcon: Bool
+ let dueBy: String?
+ let patchId: Int?
+ let policyName: String?
+
+ static func == (lhs: PendingJamfUpdate, rhs: PendingJamfUpdate) -> Bool {
+ return lhs.id == rhs.id
+ }
+}
+
+struct Policy {
+ let id: Int
+ let name: String
+ let policyVersion: String?
+ let installedOrUpdated: Date?
+ let installStatus: Int?
+ let iconUrl: String?
+ let postInstallText: String?
+}
+
+struct Patch {
+ let id: Int
+ let name: String
+ let version: String
+ let availableDate: Date?
+ let deadlineDate: Date?
+ let buttonText: String?
+ let installStatus: Int?
+}
diff --git a/SupportCompanion/Models/SidebarItem.swift b/SupportCompanion/Models/SidebarItem.swift
index 435cecd..bc97a28 100644
--- a/SupportCompanion/Models/SidebarItem.swift
+++ b/SupportCompanion/Models/SidebarItem.swift
@@ -12,7 +12,8 @@ struct SidebarItem: Identifiable, Hashable, Equatable {
let label: String
let systemImage: String
let destination: AnyView
-
+ var badge: Int = 0
+
var id: String { label }
func hash(into hasher: inout Hasher) {
diff --git a/SupportCompanion/Models/SystemUpdates.swift b/SupportCompanion/Models/SystemUpdates.swift
index 63e583d..a3a8b0d 100644
--- a/SupportCompanion/Models/SystemUpdates.swift
+++ b/SupportCompanion/Models/SystemUpdates.swift
@@ -11,8 +11,9 @@ struct SystemUpdates: Identifiable, Equatable {
let id: UUID
let count: Int
let updates: [String]
+ let hasBackgroundSecurityImprovement: Bool
static func == (lhs: SystemUpdates, rhs: SystemUpdates) -> Bool {
- return lhs.count == rhs.count && lhs.updates == rhs.updates
+ return lhs.count == rhs.count && lhs.updates == rhs.updates && lhs.hasBackgroundSecurityImprovement == rhs.hasBackgroundSecurityImprovement
}
}
diff --git a/SupportCompanion/Models/WebViewState.swift b/SupportCompanion/Models/WebViewState.swift
index 63765de..76b9ee9 100644
--- a/SupportCompanion/Models/WebViewState.swift
+++ b/SupportCompanion/Models/WebViewState.swift
@@ -6,15 +6,14 @@
//
import Foundation
+import Observation
import WebKit
-import Foundation
-import WebKit
-
-class WebViewState: NSObject, ObservableObject, WKNavigationDelegate {
- @Published var isLoading: Bool = false
- @Published var progress: Double = 0.0
- private var webViewInstance: WKWebView?
+@Observable
+class WebViewState: NSObject, WKNavigationDelegate {
+ var isLoading: Bool = false
+ var progress: Double = 0.0
+ @ObservationIgnored private var webViewInstance: WKWebView?
var webView: WKWebView {
if let webView = webViewInstance {
@@ -23,15 +22,15 @@ class WebViewState: NSObject, ObservableObject, WKNavigationDelegate {
let webView = WKWebView()
webView.navigationDelegate = self
observeProgress(for: webView)
- DispatchQueue.main.async { [weak webView] in
+ DispatchQueue.main.async { [weak webView, url = self.url] in
guard let webView = webView else { return }
- webView.load(URLRequest(url: self.url))
+ webView.load(URLRequest(url: url))
}
webViewInstance = webView
return webView
}
- private let url: URL
+ @ObservationIgnored private let url: URL
init(url: URL) {
self.url = url
@@ -41,7 +40,17 @@ class WebViewState: NSObject, ObservableObject, WKNavigationDelegate {
webView.addObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress), options: .new, context: nil)
}
+ /// Aborts any in-flight load when the view disappears. The KVO observer and
+ /// navigation delegate are deliberately left in place: this instance is cached by
+ /// WebViewStateManager and reused when the user navigates back, and tearing the
+ /// observer down here would make deinit remove it a second time.
+ func stopLoading() {
+ webViewInstance?.stopLoading()
+ }
+
deinit {
+ webViewInstance?.stopLoading()
+ webViewInstance?.navigationDelegate = nil
webViewInstance?.removeObserver(self, forKeyPath: #keyPath(WKWebView.estimatedProgress))
}
diff --git a/SupportCompanion/Preferences.swift b/SupportCompanion/Preferences.swift
deleted file mode 100644
index a5151a5..0000000
--- a/SupportCompanion/Preferences.swift
+++ /dev/null
@@ -1,386 +0,0 @@
-//
-// Preferences.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-13.
-//
-
-import Foundation
-import SwiftUI
-import Combine
-
-class Preferences: ObservableObject {
- enum NotificationType: String {
- case softwareUpdate = "LastSoftwareUpdateNotificationTime"
- case rebootReminder = "LastRebootReminderNotificationTime"
- case generic = "LastGenericNotificationTime"
- case appUpdate = "LastAppUpdateNotificationTime"
- }
-
- // MARK: - Notifications
-
- @AppStorage(NotificationType.softwareUpdate.rawValue) var lastSoftwareUpdateNotificationTime: String = ""
-
- @AppStorage(NotificationType.rebootReminder.rawValue) var lastRebootReminderNotificationTime: String = ""
-
- @AppStorage(NotificationType.generic.rawValue) var lastGenericNotificationTime: String = ""
-
- @AppStorage(NotificationType.appUpdate.rawValue) var lastAppUpdateNotificationTime: String = ""
-
- @AppStorage("NotificationTitle") var notificationTitle: String = "Support Companion"
-
- @AppStorage("NotificationInterval") var notificationInterval: Int = 4
-
- @AppStorage("NotifcationImage") var notificationImage: String = ""
-
- @AppStorage("SoftwareUpdateNotificationButtonText") var softwareUpdateNotificationButtonText: String = Constants.Notifications.SoftwareUpdate.UpdateNotificationButtonText
-
- @AppStorage("SoftwareUpdateNotificationCommand") var softwareUpdateNotificationCommand: String = "open \(Constants.Panels.softwareUpdates)"
-
- @AppStorage("SoftwareUpdateNotificationMessage") var softwareUpdateNotificationMessage: String = Constants.Notifications.SoftwareUpdate.UpdateNotificationMessage
-
- @AppStorage("AppUpdateNotificationMessage") var appUpdateNotificationMessage: String = Constants.Notifications.AppUpdate.UpdateNotificationMessage
-
- @AppStorage("AppUpdateNotificationButtonText") var appUpdateNotificationButtonText: String = Constants.Notifications.AppUpdate.UpdateNotificationButtonText
-
- @AppStorage("AppUpdateNotificationCommand") var appUpdateNotificationCommand: String = ""
-
- @AppStorage("RebootReminderDays") var rebootReminderDays: Int = 0
-
- // MARK: - branding
-
- @AppStorage("BrandName") var brandName: String = "Support Companion"
-
- @AppStorage("BrandLogo") var brandLogo: String = ""
-
- @AppStorage("BrandLogoLight") var brandLogoLight: String = ""
-
- @AppStorage("AccentColor") var accentColor: String?
-
- // MARK: - Menu
-
- @AppStorage("MenuShowIdentity") var menuShowIdentity: Bool = true
-
- @AppStorage("MenuShowApps") var menuShowApps: Bool = true
-
- @AppStorage("MenuShowSelfService") var menuShowSelfService: Bool = true
-
- @AppStorage("MenuShowCompanyPortal") var menuShowCompanyPortal: Bool = true
-
- @AppStorage("MenuShowKnowledgeBase") var menuShowKnowledgeBase: Bool = true
-
- @AppStorage("KnowledgeBaseUrl") var knowledgeBaseUrl: String = ""
-
- @AppStorage("ShowLogoInTrayMenu") var showLogoInTrayMenu: Bool = true
-
- @AppStorage("MarkdownFilePath") var markdownFilePath: String = ""
-
- @AppStorage("MarkdownMenuLabel") var markdownMenuLabel: String = ""
-
- @AppStorage("MarkdownMenuIcon") var markdownMenuIcon: String = ""
-
- @AppStorage("CustomCardsMenuLabel") var customCardsMenuLabel: String = ""
-
- @AppStorage("CustomCardsMenuIcon") var customCardsMenuIcon: String = ""
-
- @AppStorage("TrayMenuBrandingIcon") var trayMenuBrandingIcon: String = ""
-
- // MARK: - Actions
-
- @AppStorage("SupportPageUrl") var supportPageURL: String = ""
-
- @AppStorage("ChangePasswordMode") var changePasswordMode: String = ""
-
- @AppStorage("ChangePasswordUrl") var changePasswordUrl: String = ""
-
- @AppStorage("Mode") var mode: String = ""
-
- @Published var actions: [Action] = []
-
- @Published var hiddenActions: [String] = UserDefaults.standard.array(forKey: "HiddenActions") as? [String] ?? []
-
- @Published var logFolders: [String] = UserDefaults.standard.array(forKey: "LogFolders") as? [String] ?? []
-
- @Published var excludedLogFolders: [String] = UserDefaults.standard.array(forKey: "ExcludedLogFolders") as? [String] ?? []
-
- @AppStorage("RequirePrivilegedActionAuthentication") var requirePrivilegedActionAuthentication: Bool = true
-
- // MARK: - Desktop Info
-
- @AppStorage("DesktopInfoBackgroundOpacity") var desktopInfoBackgroundOpacity: Double = 0.001
-
- @AppStorage("DesktopInfoBackgroundFrosted") var desktopInfoBackgroundFrosted: Bool = false
-
- @AppStorage("DesktopInfoWindowPosition") var desktopInfoWindowPosition: String = "LowerRight"
- @Published var currentWindowPosition: String = "LowerRight"
-
- @AppStorage("ShowDesktopInfo") var showDesktopInfo: Bool = false
-
- @AppStorage("DesktopInfoFontSize") var desktopInfoFontSize: Int = 14
-
- @AppStorage("DesktopInfoLevel") var desktopInfoLevel: Int = 4
-
- @Published var desktopInfoHideItems: [String] = UserDefaults.standard.array(forKey: "DesktopInfoHideItems") as? [String] ?? []
-
- // MARK: - Home
-
- @AppStorage("CustomCardPath") var customCardPath: String = ""
-
- @Published var hiddenCards: [String] = UserDefaults.standard.array(forKey: "HiddenCards") as? [String] ?? []
-
- private var cancellable: AnyCancellable?
-
- private var cancellables = Set()
-
- // MARK: - Support info
-
- @AppStorage("SupportEmail") var supportEmail: String = ""
-
- @AppStorage("SupportPhone") var supportPhone: String = ""
-
- // MARK: - Elevate privileges
-
- @AppStorage("EnableElevation") var enableElevation: Bool = false
-
- @AppStorage("ShowElevateTrayCard") var showElevateTrayCard: Bool = true
-
- @AppStorage("MaxElevationTime") var maxElevationTime: Int = 5
-
- @AppStorage("RequireResonForElevation") var requireReasonForElevation: Bool = true
-
- @AppStorage("ReasonMinLength") var reasonMinLength: Int = 10
-
- @AppStorage("ElevationWebhookUrl") var elevationWebhookURL: String = ""
-
- @AppStorage("ElevationSeverity") var elevationSeverity: Int = 6 // Default to "Informational"
-
- var mdm: String = "Unknown"
-
- init() {
- ensureDefaultsInitialized()
-
- NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
- .sink { [weak self] _ in
- guard let self = self else { return }
- DispatchQueue.main.async {
- self.currentWindowPosition = self.desktopInfoWindowPosition
- }
- }
- .store(in: &cancellables)
-
- // Observe changes to UserDefaults specifically for complex types
- cancellable = NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
- .sink { [weak self] _ in
- self?.loadHiddenCards()
- self?.loadLogFolders()
- self?.loadExcludedLogFolders()
- self?.loadActions()
- self?.loadHiddenActions()
- self?.loadDesktopInfoHideItems()
- }
- Task {
- await detectModeAndSetLogFolders()
- }
- }
-
- private func detectModeAndSetLogFolders() async {
- //if !logFolders.isEmpty {
- // Logger.shared.logDebug("Log folders already initialized: \(logFolders)")
- // return
- //}
-
- guard mode.isEmpty else {
- Logger.shared.logDebug("Mode is already set to \(mode), skipping detection.")
- return
- }
-
- let fileManager = FileManager.default
- let companyPortalExists = fileManager.fileExists(atPath: Constants.AppPaths.companyPortal)
- let mscExists = fileManager.fileExists(atPath: Constants.AppPaths.MSC)
- let mdmUrl = await getMDMUrl()
-
- print(mdmUrl)
-
- if mdmUrl != "Unknown" {
- Logger.shared.logDebug("MDM URL detected: \(mdmUrl)")
- if mdmUrl.contains("i.manage.microsoft.com") {
- Logger.shared.logDebug("MDM URL contains i.manage.microsoft.com, setting mdm to Intune.")
- mdm = "Intune"
- }
- }
-
- if companyPortalExists && mscExists {
- Logger.shared.logDebug("Both Munki and Company Portal paths exist, defaulting to Munki mode.")
- mode = Constants.modes.munki
- logFolders = ["/Library/Managed Installs/Logs", "/Library/Logs/Microsoft"]
- } else if companyPortalExists && mdm == "Intune" {
- Logger.shared.logDebug("Company Portal path exists, setting mode to Intune.")
- mode = Constants.modes.intune
- logFolders = ["/Library/Logs/Microsoft"]
- } else if mscExists {
- Logger.shared.logDebug("MSC path exists, setting mode to Munki.")
- mode = Constants.modes.munki
- logFolders = ["/Library/Managed Installs/Logs"]
- } else {
- Logger.shared.logDebug("No paths exist, defaulting mode to System Profiler.")
- mode = Constants.modes.systemProfiler
- logFolders = []
- }
-
- UserDefaults.standard.set(mode, forKey: "Mode")
- saveLogFoldersToDefaults()
- Logger.shared.logDebug("Final mode: \(mode), log folders: \(logFolders)")
- }
-
- // MARK: - Save Log Folders to UserDefaults
- private func saveLogFoldersToDefaults() {
- UserDefaults.standard.set(logFolders, forKey: "LogFolders")
- Logger.shared.logDebug("Log folders saved to UserDefaults: \(logFolders)")
- }
-
- private func loadHiddenCards() {
- // Fetch hidden cards asynchronously to avoid modifying `@Published` directly during a view update
- DispatchQueue.main.async { [weak self] in
- self?.hiddenCards = UserDefaults.standard.array(forKey: "HiddenCards") as? [String] ?? []
- }
- }
-
- private func loadLogFolders() {
- DispatchQueue.main.async { [weak self] in
- self?.logFolders = UserDefaults.standard.array(forKey: "LogFolders") as? [String] ?? []
- }
- }
-
- private func loadHiddenActions() {
- DispatchQueue.main.async { [weak self] in
- self?.hiddenActions = UserDefaults.standard.array(forKey: "HiddenActions") as? [String] ?? []
- }
- }
-
- private func loadExcludedLogFolders() {
- DispatchQueue.main.async { [weak self] in
- self?.excludedLogFolders = UserDefaults.standard.array(forKey: "ExcludedLogFolders") as? [String] ?? []
- }
- }
-
-
- private func loadDesktopInfoHideItems() {
- DispatchQueue.main.async { [weak self] in
- self?.desktopInfoHideItems = UserDefaults.standard.array(forKey: "DesktopInfoHideItems") as? [String] ?? []
- }
- }
-
- private func loadActions() {
- DispatchQueue.main.async { [weak self] in
- let actions = UserDefaults.standard.array(forKey: "Actions") as? [[String: Any]] ?? []
- let newActions = actions.compactMap { dict in
- Action(
- id: UUID(),
- name: dict["Name"] as? String ?? "Unnamed",
- command: dict["Command"] as? String ?? "",
- icon: dict["Icon"] as? String,
- isPrivileged: dict["IsPrivileged"] as? Bool ?? false,
- description: dict["Description"] as? String ?? "",
- buttonLabel: dict["ButtonLabel"] as? String ?? "Run"
- )
- }
- self?.actions = newActions
- }
- }
-
- struct DefaultValues {
- static let values: [String: Any] = [
- "LastSoftwareUpdateNotificationTime": "",
- "lastRebootReminderNotificationTime": "",
- "LastGenericNotificationTime": "",
- "LastAppUpdateNotificationTime": "",
- "NotificationTitle": "Support Companion",
- "NotificationInterval": 4,
- "NotificationImage": "",
- "SoftwareUpdateNotificationButtonText": Constants.Notifications.SoftwareUpdate.UpdateNotificationButtonText,
- "SoftwareUpdateNotificationCommand": "open \(Constants.Panels.softwareUpdates)",
- "SoftwareUpdateNotificationMessage": Constants.Notifications.SoftwareUpdate.UpdateNotificationMessage,
- "AppUpdateNotificationMessage": Constants.Notifications.AppUpdate.UpdateNotificationMessage,
- "AppUpdateNotificationButtonText": Constants.Notifications.AppUpdate.UpdateNotificationButtonText,
- "AppUpdateNotificationCommand": "",
- "BrandName": "Support Companion",
- "BrandLogo": "",
- "AccentColor": "",
- "MenuShowIdentity": true,
- "MenuShowApps": true,
- "MenuShowSelfService": true,
- "MenuShowCompanyPortal": true,
- "MenuShowKnowledgeBase": true,
- "KnowledgeBaseUrl": "",
- "SupportPageUrl": "",
- "ChangePasswordMode": "",
- "ChangePasswordUrl": "",
- "Mode": "",
- "DesktopInfoBackgroundOpacity": 0.001,
- "DesktopInfoWindowPosition": "LowerRight",
- "ShowDesktopInfo": false,
- "DesktopInfoFontSize": 14,
- "DesktopInfoLevel": 4,
- "SupportEmail": "",
- "SupportPhone": ""
- ]
- }
-
- func ensureDefaultsInitialized() {
- let defaults = UserDefaults.standard
-
- for (key, value) in DefaultValues.values {
- if defaults.object(forKey: key) == nil {
- defaults.set(value, forKey: key)
- }
- }
- }
-
-
- func resetUserDefaults() {
- let bundleIdentifier = "com.github.macadmins.SupportCompanion"
- let defaults = UserDefaults.standard
-
- // Clear the current UserDefaults domain
- defaults.removePersistentDomain(forName: bundleIdentifier)
- defaults.synchronize()
-
- // Write all default values directly using a shell command
- for (key, value) in DefaultValues.values {
- let writeCommand: String
- if let value = value as? String {
- writeCommand = "defaults write \(bundleIdentifier) \(key) '\(value)'"
- } else if let value = value as? Bool {
- writeCommand = "defaults write \(bundleIdentifier) \(key) -bool \(value)"
- } else if let value = value as? Int {
- writeCommand = "defaults write \(bundleIdentifier) \(key) -int \(value)"
- } else if let value = value as? Double {
- writeCommand = "defaults write \(bundleIdentifier) \(key) -float \(value)"
- } else {
- Logger.shared.logError("Unsupported value type for key: \(key)")
- continue
- }
-
- // Execute the write command
- executeShellCommand(command: writeCommand)
- }
-
- Task {
- await detectModeAndSetLogFolders()
- }
-
- Logger.shared.logDebug("Defaults have been reset using defaults write.")
- }
-
- func executeShellCommand(command: String) {
- let process = Process()
- process.launchPath = "/bin/zsh"
- process.arguments = ["-c", command]
- process.launch()
- process.waitUntilExit()
- }
-}
-
-extension NSNotification.Name {
- static let desktopInfoPositionChanged = NSNotification.Name("desktopInfoPositionChanged")
-}
diff --git a/SupportCompanion/Preferences/BrandingPreferences.swift b/SupportCompanion/Preferences/BrandingPreferences.swift
new file mode 100644
index 0000000..1db8847
--- /dev/null
+++ b/SupportCompanion/Preferences/BrandingPreferences.swift
@@ -0,0 +1,28 @@
+//
+// BrandingPreferences.swift
+// SupportCompanion
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+class BrandingPreferences {
+ var brandName: String {
+ get { DefaultsStore.value(forKey: "BrandName", default: "Support Companion") }
+ set { DefaultsStore.set(newValue, forKey: "BrandName") }
+ }
+ var brandLogo: String {
+ get { DefaultsStore.value(forKey: "BrandLogo", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "BrandLogo") }
+ }
+ var brandLogoLight: String {
+ get { DefaultsStore.value(forKey: "BrandLogoLight", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "BrandLogoLight") }
+ }
+ var accentColor: String? {
+ get { DefaultsStore.optionalValue(forKey: "AccentColor") }
+ set { DefaultsStore.setOptional(newValue, forKey: "AccentColor") }
+ }
+}
diff --git a/SupportCompanion/Preferences/DefaultsStore.swift b/SupportCompanion/Preferences/DefaultsStore.swift
new file mode 100644
index 0000000..df1dc70
--- /dev/null
+++ b/SupportCompanion/Preferences/DefaultsStore.swift
@@ -0,0 +1,64 @@
+//
+// DefaultsStore.swift
+// SupportCompanion
+//
+
+import Foundation
+import Observation
+
+/// Backing store for the preference classes.
+///
+/// `@AppStorage` only works in SwiftUI views, not in `@Observable` classes, so preferences are plain
+/// properties that read and write `UserDefaults` through this store. Every read also reads `revision`,
+/// and `revision` is bumped whenever the defaults may have changed, so views showing a preference
+/// update when it changes, whether the app, an MDM profile, or `defaults write` changed it.
+@MainActor
+@Observable
+final class DefaultsStore {
+ static let shared = DefaultsStore()
+
+ private(set) var revision = 0
+
+ @ObservationIgnored private var observer: NSObjectProtocol?
+
+ private init() {
+ // Posted synchronously on the thread that changed the defaults
+ observer = NotificationCenter.default.addObserver(
+ forName: UserDefaults.didChangeNotification, object: nil, queue: nil
+ ) { _ in
+ if Thread.isMainThread {
+ MainActor.assumeIsolated { DefaultsStore.shared.defaultsChanged() }
+ } else {
+ Task { @MainActor in DefaultsStore.shared.defaultsChanged() }
+ }
+ }
+ }
+
+ /// Call when defaults may have changed without a `UserDefaults.didChangeNotification`,
+ /// e.g. after another process wrote the preferences plist.
+ func defaultsChanged() {
+ revision &+= 1
+ }
+
+ static func value(forKey key: String, default defaultValue: Value) -> Value {
+ _ = shared.revision
+ return UserDefaults.standard.object(forKey: key) as? Value ?? defaultValue
+ }
+
+ static func optionalValue(forKey key: String) -> Value? {
+ _ = shared.revision
+ return UserDefaults.standard.object(forKey: key) as? Value
+ }
+
+ static func set(_ value: Value, forKey key: String) {
+ UserDefaults.standard.set(value, forKey: key)
+ }
+
+ static func setOptional(_ value: Value?, forKey key: String) {
+ if let value {
+ UserDefaults.standard.set(value, forKey: key)
+ } else {
+ UserDefaults.standard.removeObject(forKey: key)
+ }
+ }
+}
diff --git a/SupportCompanion/Preferences/DesktopInfoPreferences.swift b/SupportCompanion/Preferences/DesktopInfoPreferences.swift
new file mode 100644
index 0000000..0bb0831
--- /dev/null
+++ b/SupportCompanion/Preferences/DesktopInfoPreferences.swift
@@ -0,0 +1,40 @@
+//
+// DesktopInfoPreferences.swift
+// SupportCompanion
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+class DesktopInfoPreferences {
+ var desktopInfoBackgroundOpacity: Double {
+ get { DefaultsStore.value(forKey: "DesktopInfoBackgroundOpacity", default: 0.001) }
+ set { DefaultsStore.set(newValue, forKey: "DesktopInfoBackgroundOpacity") }
+ }
+ var desktopInfoBackgroundFrosted: Bool {
+ get { DefaultsStore.value(forKey: "DesktopInfoBackgroundFrosted", default: false) }
+ set { DefaultsStore.set(newValue, forKey: "DesktopInfoBackgroundFrosted") }
+ }
+ var desktopInfoWindowPosition: String {
+ get { DefaultsStore.value(forKey: "DesktopInfoWindowPosition", default: "LowerRight") }
+ set { DefaultsStore.set(newValue, forKey: "DesktopInfoWindowPosition") }
+ }
+ var showDesktopInfo: Bool {
+ get { DefaultsStore.value(forKey: "ShowDesktopInfo", default: false) }
+ set { DefaultsStore.set(newValue, forKey: "ShowDesktopInfo") }
+ }
+ var desktopInfoFontSize: Int {
+ get { DefaultsStore.value(forKey: "DesktopInfoFontSize", default: 14) }
+ set { DefaultsStore.set(newValue, forKey: "DesktopInfoFontSize") }
+ }
+ var desktopInfoLevel: Int {
+ get { DefaultsStore.value(forKey: "DesktopInfoLevel", default: 4) }
+ set { DefaultsStore.set(newValue, forKey: "DesktopInfoLevel") }
+ }
+ var desktopInfoHideItems: [String] {
+ get { DefaultsStore.value(forKey: "DesktopInfoHideItems", default: []) }
+ set { DefaultsStore.set(newValue, forKey: "DesktopInfoHideItems") }
+ }
+}
diff --git a/SupportCompanion/Preferences/ElevationPreferences.swift b/SupportCompanion/Preferences/ElevationPreferences.swift
new file mode 100644
index 0000000..50e1ec3
--- /dev/null
+++ b/SupportCompanion/Preferences/ElevationPreferences.swift
@@ -0,0 +1,31 @@
+//
+// ElevationPreferences.swift
+// SupportCompanion
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+class ElevationPreferences {
+ var showElevateTrayCard: Bool {
+ get { DefaultsStore.value(forKey: "ShowElevateTrayCard", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "ShowElevateTrayCard") }
+ }
+
+ // Elevation grants admin rights, so these are only read from administrator-managed preferences.
+ // See TrustedPreferences.
+ var enableElevation: Bool { trusted.bool(forKey: "EnableElevation", default: false) }
+ var maxElevationTime: Int { trusted.int(forKey: "MaxElevationTime", default: 5) }
+ var requireReasonForElevation: Bool { trusted.bool(forKey: "RequireResonForElevation", default: true) }
+ var reasonMinLength: Int { trusted.int(forKey: "ReasonMinLength", default: 10) }
+ var elevationWebhookURL: String { trusted.string(forKey: "ElevationWebhookUrl", default: "") }
+ var elevationSeverity: Int { trusted.int(forKey: "ElevationSeverity", default: 6) }
+
+ /// TrustedPreferences reads UserDefaults directly, so register the DefaultsStore revision to stay observable
+ private var trusted: TrustedPreferences.Type {
+ _ = DefaultsStore.shared.revision
+ return TrustedPreferences.self
+ }
+}
diff --git a/SupportCompanion/Preferences/NotificationPreferences.swift b/SupportCompanion/Preferences/NotificationPreferences.swift
new file mode 100644
index 0000000..f3574c7
--- /dev/null
+++ b/SupportCompanion/Preferences/NotificationPreferences.swift
@@ -0,0 +1,77 @@
+//
+// NotificationPreferences.swift
+// SupportCompanion
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+class NotificationPreferences {
+ // MARK: - Notification timestamps
+ var lastSoftwareUpdateNotificationTime: String {
+ get { DefaultsStore.value(forKey: "LastSoftwareUpdateNotificationTime", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "LastSoftwareUpdateNotificationTime") }
+ }
+ var lastRebootReminderNotificationTime: String {
+ get { DefaultsStore.value(forKey: "LastRebootReminderNotificationTime", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "LastRebootReminderNotificationTime") }
+ }
+ var lastGenericNotificationTime: String {
+ get { DefaultsStore.value(forKey: "LastGenericNotificationTime", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "LastGenericNotificationTime") }
+ }
+ var lastAppUpdateNotificationTime: String {
+ get { DefaultsStore.value(forKey: "LastAppUpdateNotificationTime", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "LastAppUpdateNotificationTime") }
+ }
+
+ // MARK: - Notification content
+ var notificationTitle: String {
+ get { DefaultsStore.value(forKey: "NotificationTitle", default: "Support Companion") }
+ set { DefaultsStore.set(newValue, forKey: "NotificationTitle") }
+ }
+ var notificationInterval: Int {
+ get { DefaultsStore.value(forKey: "NotificationInterval", default: 4) }
+ set { DefaultsStore.set(newValue, forKey: "NotificationInterval") }
+ }
+ var notificationImage: String {
+ get { DefaultsStore.value(forKey: "NotificationImage", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "NotificationImage") }
+ }
+
+ // MARK: - Software update notification
+ var softwareUpdateNotificationButtonText: String {
+ get { DefaultsStore.value(forKey: "SoftwareUpdateNotificationButtonText", default: Constants.Notifications.SoftwareUpdate.UpdateNotificationButtonText) }
+ set { DefaultsStore.set(newValue, forKey: "SoftwareUpdateNotificationButtonText") }
+ }
+ var softwareUpdateNotificationCommand: String {
+ get { DefaultsStore.value(forKey: "SoftwareUpdateNotificationCommand", default: "open \(Constants.Panels.softwareUpdates)") }
+ set { DefaultsStore.set(newValue, forKey: "SoftwareUpdateNotificationCommand") }
+ }
+ var softwareUpdateNotificationMessage: String {
+ get { DefaultsStore.value(forKey: "SoftwareUpdateNotificationMessage", default: Constants.Notifications.SoftwareUpdate.UpdateNotificationMessage) }
+ set { DefaultsStore.set(newValue, forKey: "SoftwareUpdateNotificationMessage") }
+ }
+
+ // MARK: - App update notification
+ var appUpdateNotificationMessage: String {
+ get { DefaultsStore.value(forKey: "AppUpdateNotificationMessage", default: Constants.Notifications.AppUpdate.UpdateNotificationMessage) }
+ set { DefaultsStore.set(newValue, forKey: "AppUpdateNotificationMessage") }
+ }
+ var appUpdateNotificationButtonText: String {
+ get { DefaultsStore.value(forKey: "AppUpdateNotificationButtonText", default: Constants.Notifications.AppUpdate.UpdateNotificationButtonText) }
+ set { DefaultsStore.set(newValue, forKey: "AppUpdateNotificationButtonText") }
+ }
+ var appUpdateNotificationCommand: String {
+ get { DefaultsStore.value(forKey: "AppUpdateNotificationCommand", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "AppUpdateNotificationCommand") }
+ }
+
+ // MARK: - Reboot reminder
+ var rebootReminderDays: Int {
+ get { DefaultsStore.value(forKey: "RebootReminderDays", default: 0) }
+ set { DefaultsStore.set(newValue, forKey: "RebootReminderDays") }
+ }
+}
diff --git a/SupportCompanion/Preferences/Preferences.swift b/SupportCompanion/Preferences/Preferences.swift
new file mode 100644
index 0000000..a9e47de
--- /dev/null
+++ b/SupportCompanion/Preferences/Preferences.swift
@@ -0,0 +1,476 @@
+//
+// Preferences.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2024-11-13.
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+class Preferences {
+
+ // MARK: - Domain sub-objects
+
+ let branding = BrandingPreferences()
+ let notifications = NotificationPreferences()
+ let elevation = ElevationPreferences()
+ let desktopInfo = DesktopInfoPreferences()
+
+ // MARK: - Menu
+
+ var menuShowIdentity: Bool {
+ get { DefaultsStore.value(forKey: "MenuShowIdentity", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "MenuShowIdentity") }
+ }
+ var menuShowApps: Bool {
+ get { DefaultsStore.value(forKey: "MenuShowApps", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "MenuShowApps") }
+ }
+ var menuShowSelfService: Bool {
+ get { DefaultsStore.value(forKey: "MenuShowSelfService", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "MenuShowSelfService") }
+ }
+ var companyPortalUrl: String {
+ get { DefaultsStore.value(forKey: "CompanyPortalUrl", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "CompanyPortalUrl") }
+ }
+ var menuShowCompanyPortal: Bool {
+ get { DefaultsStore.value(forKey: "MenuShowCompanyPortal", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "MenuShowCompanyPortal") }
+ }
+ var menuShowKnowledgeBase: Bool {
+ get { DefaultsStore.value(forKey: "MenuShowKnowledgeBase", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "MenuShowKnowledgeBase") }
+ }
+ var knowledgeBaseUrl: String {
+ get { DefaultsStore.value(forKey: "KnowledgeBaseUrl", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "KnowledgeBaseUrl") }
+ }
+ var showLogoInTrayMenu: Bool {
+ get { DefaultsStore.value(forKey: "ShowLogoInTrayMenu", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "ShowLogoInTrayMenu") }
+ }
+ var markdownFilePath: String {
+ get { DefaultsStore.value(forKey: "MarkdownFilePath", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "MarkdownFilePath") }
+ }
+ var markdownMenuLabel: String {
+ get { DefaultsStore.value(forKey: "MarkdownMenuLabel", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "MarkdownMenuLabel") }
+ }
+ var markdownMenuIcon: String {
+ get { DefaultsStore.value(forKey: "MarkdownMenuIcon", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "MarkdownMenuIcon") }
+ }
+ var customCardsMenuLabel: String {
+ get { DefaultsStore.value(forKey: "CustomCardsMenuLabel", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "CustomCardsMenuLabel") }
+ }
+ var customCardsMenuIcon: String {
+ get { DefaultsStore.value(forKey: "CustomCardsMenuIcon", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "CustomCardsMenuIcon") }
+ }
+ var trayMenuBrandingIcon: String {
+ get { DefaultsStore.value(forKey: "TrayMenuBrandingIcon", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "TrayMenuBrandingIcon") }
+ }
+ var trayMenuShowIcon: Bool {
+ get { DefaultsStore.value(forKey: "TrayMenuShowIcon", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "TrayMenuShowIcon") }
+ }
+
+ // MARK: - Actions
+
+ var supportPageURL: String {
+ get { DefaultsStore.value(forKey: "SupportPageUrl", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "SupportPageUrl") }
+ }
+ var changePasswordMode: String {
+ get { DefaultsStore.value(forKey: "ChangePasswordMode", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "ChangePasswordMode") }
+ }
+ var changePasswordUrl: String {
+ get { DefaultsStore.value(forKey: "ChangePasswordUrl", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "ChangePasswordUrl") }
+ }
+ var mode: String {
+ get { DefaultsStore.value(forKey: "Mode", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "Mode") }
+ }
+ // Only an administrator may turn off authentication for privileged actions. See TrustedPreferences.
+ var requirePrivilegedActionAuthentication: Bool {
+ TrustedPreferences.bool(forKey: "RequirePrivilegedActionAuthentication", default: true)
+ }
+
+ // MARK: - User installs
+
+ /// Whether to take an interest in installers the user opens.
+ ///
+ /// Presentation only, as everywhere else here: the helper re-reads this and the allowlist before it
+ /// does anything, and its answer is the one that counts. This only decides whether to bother
+ /// staging a file the user just opened.
+ var enableUserInstalls: Bool {
+ TrustedPreferences.bool(forKey: "EnableUserInstalls", default: false)
+ }
+
+ /// Whether Finder offers "Install with Support Companion" when someone right-clicks an installer.
+ ///
+ /// Follows `EnableUserInstalls` unless an administrator says otherwise, so an organisation that
+ /// does not use the feature never sees the menu item, and one that does can still hide it — for
+ /// instance to keep the only route the catalog inside the app.
+ var showInstallerServiceMenuItem: Bool {
+ guard TrustedPreferences.object(forKey: "ShowInstallerServiceMenuItem") != nil else {
+ return enableUserInstalls
+ }
+
+ return TrustedPreferences.bool(forKey: "ShowInstallerServiceMenuItem", default: true)
+ }
+
+ /// Parsed from the Actions preference; reloaded when defaults change. See loadActions().
+ var actions: [Action] = []
+ var hiddenActions: [String] {
+ get { DefaultsStore.value(forKey: "HiddenActions", default: []) }
+ set { DefaultsStore.set(newValue, forKey: "HiddenActions") }
+ }
+ var logFolders: [String] {
+ get { DefaultsStore.value(forKey: "LogFolders", default: []) }
+ set { DefaultsStore.set(newValue, forKey: "LogFolders") }
+ }
+ var excludedLogFolders: [String] {
+ get { DefaultsStore.value(forKey: "ExcludedLogFolders", default: []) }
+ set { DefaultsStore.set(newValue, forKey: "ExcludedLogFolders") }
+ }
+
+ // MARK: - Home / Cards
+
+ var customCardPath: String {
+ get { DefaultsStore.value(forKey: "CustomCardPath", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "CustomCardPath") }
+ }
+
+ var hiddenCards: [String] {
+ get { DefaultsStore.value(forKey: "HiddenCards", default: []) }
+ set { DefaultsStore.set(newValue, forKey: "HiddenCards") }
+ }
+
+ // MARK: - Fleet
+
+ var fleetNotifyInstallResults: Bool {
+ DefaultsStore.value(forKey: "FleetNotifyInstallResults", default: true)
+ }
+ var fleetNotifyUpdates: Bool {
+ DefaultsStore.value(forKey: "FleetNotifyUpdates", default: true)
+ }
+ var fleetNotifyPolicies: Bool {
+ DefaultsStore.value(forKey: "FleetNotifyPolicies", default: true)
+ }
+ /// Off unless an administrator turns it on: unlike the others this one asks the user to do
+ /// something rather than telling them something, so it isn't imposed by default.
+ var fleetNotifySignIn: Bool {
+ DefaultsStore.value(forKey: "FleetNotifySignIn", default: false)
+ }
+
+ // MARK: - Support info
+
+ var supportEmail: String {
+ get { DefaultsStore.value(forKey: "SupportEmail", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "SupportEmail") }
+ }
+ var supportPhone: String {
+ get { DefaultsStore.value(forKey: "SupportPhone", default: "") }
+ set { DefaultsStore.set(newValue, forKey: "SupportPhone") }
+ }
+
+ // MARK: - General
+
+ var refreshSelfService: Bool {
+ get { DefaultsStore.value(forKey: "RefreshSelfService", default: true) }
+ set { DefaultsStore.set(newValue, forKey: "RefreshSelfService") }
+ }
+ var jamfLogPollHours: Int {
+ get { DefaultsStore.value(forKey: "JamfLogPollHours", default: 36) }
+ set { DefaultsStore.set(newValue, forKey: "JamfLogPollHours") }
+ }
+ var debugLogging: Bool {
+ get { DefaultsStore.value(forKey: "DebugLogging", default: false) }
+ set { DefaultsStore.set(newValue, forKey: "DebugLogging") }
+ }
+
+ var mdm: String = "Unknown"
+
+ // MARK: - Private state
+
+ @ObservationIgnored private var defaultsObserver: NSObjectProtocol?
+ @ObservationIgnored private var prefsDirSource: DispatchSourceFileSystemObject?
+
+ // MARK: - Init
+
+ init() {
+ ensureDefaultsInitialized()
+ loadActions()
+ Logger.shared.setFileDebugLogging(fileDebugLoggingEnabled)
+
+ // Plain preferences are read from UserDefaults through DefaultsStore and stay current on their own.
+ // Only derived state needs reloading here.
+ defaultsObserver = NotificationCenter.default.addObserver(
+ forName: UserDefaults.didChangeNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ MainActor.assumeIsolated {
+ guard let self else { return }
+ Logger.shared.setFileDebugLogging(self.fileDebugLoggingEnabled)
+ self.loadActions()
+ }
+ }
+
+ startWatchingPreferencesDirectory()
+
+ Task {
+ await detectModeAndSetLogFolders()
+ }
+ }
+
+ private var fileDebugLoggingEnabled: Bool {
+ let value = UserDefaults.standard.object(forKey: "FileDebugLogging")
+ return (value as? Bool) ?? (value as? NSNumber)?.boolValue ?? debugLogging
+ }
+
+ // MARK: - Preferences file watcher
+
+ /// `defaults write` from another process doesn't post `UserDefaults.didChangeNotification` in this
+ /// process, so watch the preferences directory and tell DefaultsStore when the plist changes.
+ private func startWatchingPreferencesDirectory() {
+ let prefsDirURL = FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/Preferences")
+
+ let fd = open(prefsDirURL.path, O_EVTONLY)
+ guard fd >= 0 else {
+ Logger.shared.logError("Preferences: failed to open preferences directory for watching: \(prefsDirURL.path)")
+ return
+ }
+ let queue = DispatchQueue(label: "com.github.macadmins.SupportCompanion.PrefsWatch")
+ let src = DispatchSource.makeFileSystemObjectSource(
+ fileDescriptor: fd,
+ eventMask: [.write, .rename, .delete, .extend, .attrib],
+ queue: queue
+ )
+ let plistPath = prefsDirURL.appendingPathComponent("com.github.macadmins.SupportCompanion.plist").path
+ var lastModified = (try? FileManager.default.attributesOfItem(atPath: plistPath))?[.modificationDate] as? Date
+
+ src.setCancelHandler { [fd] in close(fd) }
+ src.setEventHandler { [weak self] in
+ // Other apps write to this directory constantly; only react when our plist changed
+ let modified = (try? FileManager.default.attributesOfItem(atPath: plistPath))?[.modificationDate] as? Date
+ guard modified != lastModified else { return }
+ lastModified = modified
+ Task { @MainActor [weak self] in
+ DefaultsStore.shared.defaultsChanged()
+ self?.loadActions()
+ }
+ }
+ src.resume()
+ prefsDirSource = src
+ Logger.shared.logDebug("Preferences: started watching \(prefsDirURL.path)")
+ }
+
+ // MARK: - Mode detection
+
+ private func detectModeAndSetLogFolders() async {
+ guard mode.isEmpty else {
+ Logger.shared.logDebug("Mode is already set to \(mode), skipping detection.")
+ return
+ }
+
+ let fileManager = FileManager.default
+ let companyPortalExists = fileManager.fileExists(atPath: Constants.AppPaths.companyPortal)
+ let selfServiceExists = fileManager.fileExists(atPath: Constants.AppPaths.selfService)
+ let mscExists = fileManager.fileExists(atPath: Constants.AppPaths.MSC)
+ let mdmUrl = await getMDMUrl()
+
+ if mdmUrl != "Unknown" {
+ Logger.shared.logDebug("MDM URL detected: \(mdmUrl)")
+
+ // getMDMUrl() returns the URL without its scheme, and a URL without a scheme has no host
+ let mdmURLString = mdmUrl.contains("://") ? mdmUrl : "https://\(mdmUrl)"
+ if let url = URL(string: mdmURLString), let host = url.host?.lowercased() {
+ let pattern = #"(^|\.)manage\.microsoft\.[a-z0-9-]{2,63}$"#
+ if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
+ let range = NSRange(host.startIndex..
+ # values, which plutil refuses to convert, so a json extraction of the whole array returns nothing.
+ elevated_users=()
+ index=0
+ while true; do
+ entry=$(/usr/bin/plutil -extract "Elevations.${index}.UserName" raw -o - "$ELEVATION_STATE" 2>/dev/null) || break
+ [ -n "$entry" ] && elevated_users+=("$entry")
+ index=$((index + 1))
+ done
+
+ # Earlier builds wrote a single elevation at the top level
+ if [ ${#elevated_users[@]} -eq 0 ]; then
+ entry=$(/usr/bin/plutil -extract UserName raw -o - "$ELEVATION_STATE" 2>/dev/null) || entry=""
+ [ -n "$entry" ] && elevated_users+=("$entry")
+ fi
+
+ for elevated_user in "${elevated_users[@]}"; do
+ log "Demoting $elevated_user"
+ _try "demote $elevated_user" /usr/sbin/dseditgroup -o edit -d "$elevated_user" -t user admin
+ done
+else
+ log "no elevation state present: $ELEVATION_STATE"
+fi
+
# --- stop processes --------------------------------------------------------
log "Stopping application and helper if running"
_try "kill app" pkill -f SupportCompanion
@@ -105,6 +138,11 @@ log "Removing installed components"
_rm_if_exists "$APP_PATH"
_rm_if_exists "$HELPER_PATH"
+# The helper's state directory, including the root-owned elevation audit log. Anyone still elevated
+# was demoted at the top of this script, so nothing here is needed any more. Keep a copy first if
+# the elevation log is wanted for an audit trail — it is removed with everything else.
+_rm_if_exists "/var/db/${APP_ID}"
+
# --- pkg receipts -------------------------------------------
forget_if_present() {
local package="$1"
diff --git a/SupportCompanion/Scripts/helper_install.zsh b/SupportCompanion/Scripts/helper_install.zsh
index 1bf274b..19a5245 100755
--- a/SupportCompanion/Scripts/helper_install.zsh
+++ b/SupportCompanion/Scripts/helper_install.zsh
@@ -10,13 +10,69 @@ privileged_helper_tool="/Library/PrivilegedHelperTools/com.github.macadmins.Supp
install_location="/Applications/SupportCompanion.app"
launch_daemon="com.github.macadmins.SupportCompanion.helper"
+# An organisation can deploy the helper declaratively instead, with
+# com.apple.configuration.services.background-tasks, which puts both the executable and its launchd
+# job in a managed directory the OS re-asserts. Installing our own copy as well would leave two
+# daemons claiming the same Mach service, so this skips the whole helper install.
+#
+# Read only from a DEVICE-scoped configuration profile, which is the single source the app and the
+# helper trust for anything privileged. A user-scoped profile is no good here either: it lands under
+# a per-user directory that an installer script has no reliable way to resolve.
+#
+# /Library/Preferences is deliberately not consulted, for the reason HelperPreferences gives: only
+# root can write it, and that is the problem. Setting this key removes the helper and its launchd
+# job, so honouring it from a root-writable file would let one root moment disable the component
+# that demotes elevated users, and every later package install would keep it disabled.
+skip_helper_install=false
+managed_domain="/Library/Managed Preferences/com.github.macadmins.SupportCompanion"
+if [[ -f "${managed_domain}.plist" ]]; then
+ value=$(/usr/bin/defaults read "$managed_domain" SkipHelperInstall 2>/dev/null)
+ [[ "$value" == "1" ]] && skip_helper_install=true
+fi
+
+# Say when the key is being ignored because of where it lives, rather than silently installing the
+# helper anyway and leaving two daemons claiming the same Mach service.
+if [[ "$skip_helper_install" == "false" ]] \
+ && [[ -f "/Library/Preferences/com.github.macadmins.SupportCompanion.plist" ]] \
+ && [[ -n "$(/usr/bin/defaults read /Library/Preferences/com.github.macadmins.SupportCompanion SkipHelperInstall 2>/dev/null)" ]]
+then
+ echo "Ignoring SkipHelperInstall in /Library/Preferences: it is only read from a device-scoped configuration profile. Deliver it through your MDM."
+fi
+
+if [[ "$skip_helper_install" == "true" ]]; then
+ echo "SkipHelperInstall is set; leaving the helper to the declarative configuration."
+ # The package payload writes this plist before any script runs, so it has to be removed rather
+ # than skipped, or it will compete with the declaratively deployed job.
+ /bin/rm -f "/Library/LaunchDaemons/${launch_daemon}.plist"
+ /bin/rm -f "${privileged_helper_tool}"
+ exit 0
+fi
+
# Create "/Library/PrivilegedHelperTools/" if not present
if [[ ! -d "/Library/PrivilegedHelperTools/" ]]; then
- mkdir "/Library/PrivilegedHelperTools/"
+ mkdir -p "/Library/PrivilegedHelperTools/"
+fi
+
+# Stop the running helper before replacing it.
+#
+# The app and the helper speak a versioned XPC interface, and the helper refuses clients older than
+# it supports. A new app left talking to an old helper is therefore broken rather than merely stale:
+# every privileged operation fails. Unloading first means the load at the end starts the executable
+# written below, and not whatever was already running.
+if launchctl print "system/${launch_daemon}" &> /dev/null ; then
+ launchctl bootout "system/${launch_daemon}" &> /dev/null \
+ || launchctl unload "/Library/LaunchDaemons/${launch_daemon}.plist" &> /dev/null
+fi
+
+# Remove before copying: unlink always succeeds, while writing over a file another process still
+# holds open is not guaranteed to on every filesystem.
+/bin/rm -f "${privileged_helper_tool}"
+
+if ! cp "${install_location}/Contents/Library/LaunchDaemons/${launch_daemon}" "${privileged_helper_tool}" ; then
+ echo "Failed to copy the privileged helper into ${privileged_helper_tool}" >&2
+ exit 1
fi
-# Copy the PrivilegedHelperTool
-cp "${install_location}/Contents/Library/LaunchServices/${launch_daemon}" "/Library/PrivilegedHelperTools/"
# Set permissions
chown root:wheel "${privileged_helper_tool}"
chmod 544 "${privileged_helper_tool}"
@@ -24,14 +80,13 @@ chmod 544 "${privileged_helper_tool}"
chown root:wheel "/Library/LaunchDaemons/${launch_daemon}.plist"
chmod 644 "/Library/LaunchDaemons/${launch_daemon}.plist"
-# Unload the LaunchDaemon
-if launchctl print "system/${launch_daemon}" &> /dev/null ; then
- launchctl unload "/Library/LaunchDaemons/${launch_daemon}.plist"
-fi
+# Load unconditionally rather than only when it looks unloaded. Skipping this because the job still
+# appears registered is how an install quietly finishes with the previous helper still running.
+launchctl load -w "/Library/LaunchDaemons/${launch_daemon}.plist" &> /dev/null
-# Load the LaunchDaemon
if ! launchctl print "system/${launch_daemon}" &> /dev/null ; then
- launchctl load -w "/Library/LaunchDaemons/${launch_daemon}.plist"
+ echo "The privileged helper did not load after installation" >&2
+ exit 1
fi
exit 0
diff --git a/SupportCompanion/Services/ExecutionService.swift b/SupportCompanion/Services/ExecutionService.swift
new file mode 100644
index 0000000..780bc4d
--- /dev/null
+++ b/SupportCompanion/Services/ExecutionService.swift
@@ -0,0 +1,155 @@
+//
+// ExecutionService.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2024-11-12.
+//
+
+import Foundation
+
+// MARK: - ExecutionService
+
+/// Runs commands, either here as the logged-in user or, for the privileged operations, by asking the
+/// helper to perform a named one.
+///
+/// The helper no longer takes a command to run. Each privileged call below names an operation the helper
+/// implements, and the helper decides what that operation executes and whether policy allows it, so code
+/// running in this process cannot turn the helper into a way to run something as root.
+enum ExecutionService {
+
+ // MARK: Unprivileged
+
+ /// Execute a command with arguments as the logged-in user.
+ static func executeCommand(_ command: String, with arguments: [String] = []) async throws -> String {
+ Logger.shared.logDebug("Executing command \(command) with arguments \(arguments))")
+ return try await ProcessRunner.runCommand(command, with: arguments)
+ }
+
+ /// Execute a shell command as the logged-in user.
+ static func executeShellCommand(_ rawCommand: String) async throws -> String {
+ guard !rawCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ Logger.shared.logDebug("Command must not be null or whitespace")
+ throw NSError(domain: "ExecutionServiceError", code: 1, userInfo: [
+ NSLocalizedDescriptionKey: "Command must not be null or whitespace"
+ ])
+ }
+
+ // The command is passed to /bin/sh as a single argument, so it must not be escaped.
+ // Escaping quotes here would corrupt any command that contains them.
+ return try await executeCommand("/bin/sh", with: ["-c", rawCommand])
+ }
+
+ // MARK: Actions
+
+ /// Run an administrator-defined action.
+ ///
+ /// A privileged action is only named to the helper; the helper looks the command up in the
+ /// administrator-managed preferences itself, so what runs as root is never taken from this process.
+ static func runAction(_ action: Action) async throws -> String {
+ if action.isPrivileged ?? false {
+ return try await HelperRemoteProvider.remote().runPrivilegedAction(named: action.name)
+ }
+
+ return try await executeShellCommand(action.command)
+ }
+
+ // MARK: Elevation
+
+ static func elevate(reason: String) async throws -> String {
+ try await HelperRemoteProvider.remote().elevate(reason: reason)
+ }
+
+ static func demote() async throws -> String {
+ try await HelperRemoteProvider.remote().demote()
+ }
+
+ /// Seconds until the helper takes administrator rights back, or 0 when no elevation is active.
+ static func elevationTimeRemaining() async throws -> Double {
+ try await HelperRemoteProvider.remote().elevationTimeRemaining()
+ }
+
+ // MARK: User installs
+
+ /// Hand an installer the user opened to the helper, which copies it somewhere only root can write
+ /// and judges that copy.
+ ///
+ /// The file is opened here, as the logged-in user, and only the descriptor is passed on. That keeps
+ /// the helper from reading anything this user could not read themselves, and means the copy it
+ /// judges is of the file the user actually opened rather than of whatever a path points at by the
+ /// time root gets to it.
+ static func stageInstaller(at url: URL) async throws -> InstallerAssessment {
+ let handle = try FileHandle(forReadingFrom: url)
+ defer { try? handle.close() }
+
+ let json = try await HelperRemoteProvider.remote().stageInstaller(handle, fileName: url.lastPathComponent)
+
+ do {
+ return try InstallerAssessment.make(fromJSON: json)
+ } catch {
+ // Decoding tolerates a helper that is a version behind, so reaching here means the answer
+ // was not one of ours at all. Say what to do about it; the raw decoding error names a
+ // missing key and helps nobody.
+ Logger.shared.logError("Unable to decode the helper's assessment: \(error)")
+
+ throw SupportCompanionErrors.helperConnection(
+ "Support Companion could not read the privileged helper's answer. The app and the helper are different versions; update both and try again."
+ )
+ }
+ }
+
+ static func installStagedInstaller(token: String) async throws -> String {
+ try await HelperRemoteProvider.remote().installStagedInstaller(token: token)
+ }
+
+ static func discardStagedInstaller(token: String) async throws {
+ _ = try await HelperRemoteProvider.remote().discardStagedInstaller(token: token)
+ }
+
+ // MARK: Jamf
+
+ static func jamfPatch(id: String) async throws -> String {
+ try await HelperRemoteProvider.remote().jamfPatch(id: id)
+ }
+
+ static func jamfSelfServicePatch(id: String, userId: String?) async throws -> String {
+ try await HelperRemoteProvider.remote().jamfSelfServicePatch(id: id, userId: userId)
+ }
+
+ static func jamfRecon() async throws -> String {
+ try await HelperRemoteProvider.remote().jamfRecon()
+ }
+
+ static func jamfLog(kind: JamfLogKind, hours: Int) async throws -> String {
+ try await HelperRemoteProvider.remote().jamfLog(kind: kind.rawValue, hours: hours)
+ }
+
+ /// The fixed Jamf log queries the helper knows how to run.
+ enum JamfLogKind: String {
+ case checkIn
+ case inventory
+ }
+
+ // MARK: Device management
+
+ static func restartIntuneAgent() async throws -> String {
+ try await HelperRemoteProvider.remote().restartIntuneAgent()
+ }
+
+ static func mdmEnrollmentDate() async throws -> String {
+ try await HelperRemoteProvider.remote().mdmEnrollmentDate()
+ }
+
+ // MARK: System
+
+ static func reboot() async throws -> String {
+ try await HelperRemoteProvider.remote().reboot()
+ }
+
+ static func cancelReboot() async throws -> String {
+ try await HelperRemoteProvider.remote().cancelReboot()
+ }
+
+ static func setIPConfigVerbose(_ enabled: Bool) async throws -> String {
+ try await HelperRemoteProvider.remote().setIPConfigVerbose(enabled)
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetButtonLabels.swift b/SupportCompanion/Services/Fleet/FleetButtonLabels.swift
new file mode 100644
index 0000000..9b5a8ac
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetButtonLabels.swift
@@ -0,0 +1,91 @@
+//
+// FleetButtonLabels.swift
+// SupportCompanion
+//
+// Custom button text for Fleet self-service titles, from the `FleetButtonLabels` preference.
+//
+// Keys are a software title's id (as a string), display name, or name; the id wins, then an exact name
+// match, then a case-insensitive one. Values are either a string, which replaces the Install label, or a
+// dictionary with any of `Install`, `Update`, `Reinstall` and `Uninstall`:
+//
+// FleetButtonLabels
+//
+// Request software
+// Request
+// 42
+//
+// Install
+// Get
+// Uninstall
+// Remove
+//
+//
+//
+
+import Foundation
+
+struct FleetButtonLabels {
+ private let entries: [String: Any]
+
+ init(_ entries: [String: Any]?) {
+ self.entries = entries ?? [:]
+ }
+
+ /// The labels from the `FleetButtonLabels` preference; views reading it update when it changes.
+ @MainActor static var current: FleetButtonLabels {
+ FleetButtonLabels(DefaultsStore.optionalValue(forKey: "FleetButtonLabels"))
+ }
+
+ func label(for title: FleetSoftwareTitle, action: FleetSoftwareTitle.Action) -> String {
+ customLabel(for: title, action: action) ?? Self.defaultLabel(for: action)
+ }
+
+ static func defaultLabel(for action: FleetSoftwareTitle.Action) -> String {
+ switch action {
+ case .install: return Constants.Fleet.install
+ case .update: return Constants.Fleet.update
+ case .reinstall: return Constants.Fleet.reinstall
+ case .uninstall: return Constants.Fleet.uninstall
+ }
+ }
+
+ private func customLabel(for title: FleetSoftwareTitle, action: FleetSoftwareTitle.Action) -> String? {
+ guard let entry = entry(for: title) else { return nil }
+
+ if let label = entry as? String {
+ return action == .install ? nonEmpty(label) : nil
+ }
+ guard let labels = entry as? [String: Any] else { return nil }
+ let key: String
+ switch action {
+ case .install: key = "Install"
+ case .update: key = "Update"
+ case .reinstall: key = "Reinstall"
+ case .uninstall: key = "Uninstall"
+ }
+ return (labels[key] as? String).flatMap(nonEmpty)
+ }
+
+ private func entry(for title: FleetSoftwareTitle) -> Any? {
+ if let entry = entries[String(title.id)] {
+ return entry
+ }
+ let names = [title.displayName, title.name].compactMap { $0 }.filter { !$0.isEmpty }
+ for name in names {
+ if let entry = entries[name] {
+ return entry
+ }
+ }
+ for name in names {
+ if let key = entries.keys.first(where: { $0.caseInsensitiveCompare(name) == .orderedSame }) {
+ return entries[key]
+ }
+ }
+ return nil
+ }
+
+ private func nonEmpty(_ string: String) -> String? {
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? nil : trimmed
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetClient.swift b/SupportCompanion/Services/Fleet/FleetClient.swift
new file mode 100644
index 0000000..b46fa68
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetClient.swift
@@ -0,0 +1,403 @@
+//
+// FleetClient.swift
+// SupportCompanion
+//
+// Client for Fleet's device-authenticated API.
+//
+// This client never retries in a loop: after a failure it refuses requests locally until a backoff
+// deadline, and while Fleet requires SSO it sends nothing gated at all.
+//
+
+import Foundation
+
+enum FleetError: Error, Equatable, LocalizedError {
+ /// No Fleet server or device token on this Mac.
+ case notConfigured
+ /// Fleet Desktop SSO is enabled and there's no valid session; sign in via FleetSSOController.
+ case ssoRequired
+ /// The device token was rejected.
+ case unauthorized
+ /// Requests are paused after earlier failures.
+ case backingOff(until: Date)
+ case rateLimited
+ case server(status: Int, message: String)
+ case network(String)
+ case decoding(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .notConfigured:
+ return "Fleet isn't set up on this Mac."
+ case .ssoRequired:
+ return "Sign in to continue."
+ case .unauthorized:
+ return "Fleet didn't accept this Mac's device token."
+ case .backingOff(let until):
+ return "Fleet is temporarily unavailable. Retrying \(until.formatted(date: .omitted, time: .shortened))."
+ case .rateLimited:
+ return "Too many requests to Fleet. Try again later."
+ case .server(let status, let message):
+ return message.isEmpty ? "Fleet returned an error (\(status))." : message
+ case .network(let message):
+ return message
+ case .decoding(let message):
+ return "Unexpected response from Fleet: \(message)"
+ }
+ }
+}
+
+/// What `POST /device/{token}/sso` answers with: where to send the user, and the short-lived
+/// handshake cookie Fleet matches the IdP's callback against.
+///
+/// Unchecked because of `HTTPCookie`, which has no `Sendable` conformance but is immutable once
+/// created — it has no settable properties — and this one is read on the main actor and never written.
+struct FleetSSOInitiation: @unchecked Sendable {
+ let idpURL: URL
+ let handshakeCookie: HTTPCookie?
+}
+
+/// The Fleet calls the software catalog needs, so managers can be tested with a fake.
+protocol FleetSoftwareAPI: Sendable {
+ /// Why Fleet can't be used on this Mac, or nil when it can.
+ nonisolated var configurationProblem: String? { get }
+ func selfServiceSoftware() async throws -> [FleetSoftwareTitle]
+ func selfServiceCategories() async throws -> [FleetSoftwareCategory]
+ func install(titleID: Int) async throws
+ func uninstall(titleID: Int) async throws
+ func installResult(installUUID: String) async throws -> FleetInstallResult?
+ func uninstallResult(executionID: String) async throws -> FleetScriptResult
+ func refetch() async throws
+}
+
+/// The Fleet calls the device info and compliance cards need.
+protocol FleetDeviceAPI: Sendable {
+ nonisolated var configurationProblem: String? { get }
+ func deviceHost() async throws -> FleetHost
+ /// Outside the SSO gate, so the compliance count survives a signed-out session.
+ func desktopSummary() async throws -> FleetDesktopSummary
+ func refetch() async throws
+}
+
+actor FleetClient: FleetSoftwareAPI, FleetDeviceAPI {
+ static let shared = FleetClient()
+
+ /// Name of the cookie Fleet sets after Fleet Desktop SSO.
+ static let ssoSessionCookieName = "__Host-FLEET_DESKTOP_SESSION"
+ /// Name of the cookie that ties the IdP's callback to the sign-in this app started. Fleet sets it
+ /// on the response to the initiation call and reads it back at the SAML callback.
+ static let ssoHandshakeCookieName = "__Host-FLEETSSOSESSIONID"
+
+ private let session: URLSession
+ private var token: FleetDeviceIdentity.Token?
+ private var ssoSessionCookie: String?
+
+ private(set) var isSSORequired = false
+ /// Whether the keychain has been consulted for a session from an earlier launch.
+ private var didRestoreSSOSession = false
+ private var consecutiveFailures = 0
+ private var blockedUntil: Date?
+
+ private static let minimumBackoff: TimeInterval = 60
+ /// Short enough to recover quickly after an outage: 1, 2, 4, then 5 minutes.
+ private static let maximumBackoff: TimeInterval = 5 * 60
+
+ init() {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = 30
+ // The SSO cookie is attached explicitly; nothing else should be stored or sent
+ configuration.httpCookieStorage = nil
+ configuration.httpShouldSetCookies = false
+ configuration.urlCache = nil
+ session = URLSession(configuration: configuration)
+ }
+
+ // MARK: - Configuration
+
+ nonisolated var configurationProblem: String? {
+ FleetDeviceIdentity.configurationProblem()
+ }
+
+ /// The host requests go to, used to scope the stored SSO session to one Fleet server.
+ private func serverHost() -> String? {
+ FleetDeviceIdentity.serverURL()?.host
+ }
+
+ /// Stores the Fleet Desktop SSO session and lifts the SSO and backoff pauses.
+ func setSSOSessionCookie(_ value: String?) {
+ ssoSessionCookie = value
+ if value != nil {
+ isSSORequired = false
+ resetBackoff()
+ }
+ }
+
+ /// Starts Fleet Desktop SSO and returns where to send the user.
+ ///
+ /// Deliberately not routed through `request`: this endpoint is outside the SSO gate, and its
+ /// errors are configuration problems (the feature is off, no IdP, mismatched hosts) rather than
+ /// signs the server is unwell, so they must not pause every other Fleet request behind a backoff.
+ func initiateSSO() async throws -> FleetSSOInitiation {
+ guard let server = FleetDeviceIdentity.serverURL() else { throw FleetError.notConfigured }
+ guard let token = currentToken() else { throw FleetError.notConfigured }
+
+ let (data, response) = try await perform("POST", "sso", query: [:], server: server, token: token.value)
+ guard (200..<300).contains(response.statusCode) else {
+ let message = (try? JSONDecoder.fleet.decode(FleetErrorResponse.self, from: data))?.summary ?? ""
+ throw FleetError.server(status: response.statusCode, message: message)
+ }
+
+ let body: FleetSSOInitiationResponse
+ do {
+ body = try JSONDecoder.fleet.decode(FleetSSOInitiationResponse.self, from: data)
+ } catch {
+ throw FleetError.decoding(String(describing: error))
+ }
+ guard let idpURL = URL(string: body.url), idpURL.scheme?.lowercased() == "https" else {
+ throw FleetError.decoding("Fleet returned an identity provider URL that isn't HTTPS")
+ }
+
+ return FleetSSOInitiation(idpURL: idpURL, handshakeCookie: Self.handshakeCookie(from: response, server: server))
+ }
+
+ /// Fleet sets the handshake cookie on this response, but the sign-in runs in a web view with its
+ /// own cookie store, so it's lifted off the headers here and planted there before the IdP loads.
+ static func handshakeCookie(from response: HTTPURLResponse, server: URL) -> HTTPCookie? {
+ guard let header = response.value(forHTTPHeaderField: "Set-Cookie") else { return nil }
+ let cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: server)
+ return cookies.first { $0.name == ssoHandshakeCookieName }
+ }
+
+ /// Takes the session a completed sign-in produced and keeps it for the next launch.
+ func completeSSO(cookie: String, expiresAt: Date?) {
+ didRestoreSSOSession = true
+ setSSOSessionCookie(cookie)
+ guard let host = serverHost() else { return }
+ FleetSSOSessionStore.save(.init(cookie: cookie, host: host, expiresAt: expiresAt))
+ }
+
+ /// Reloads a sign-in from an earlier launch, if it hasn't expired and still belongs to this server.
+ ///
+ /// Done on the way into the first request rather than at startup, so that nothing can get ahead of
+ /// it: a gated request sent before the session was loaded would come back asking for SSO, and that
+ /// answer discards the stored session — which is the one it should have been sending.
+ private func restoreSSOSessionIfNeeded() {
+ guard !didRestoreSSOSession else { return }
+ didRestoreSSOSession = true
+ guard ssoSessionCookie == nil, let host = serverHost() else { return }
+ guard let session = FleetSSOSessionStore.load(host: host) else { return }
+ Logger.shared.logDebug("Fleet: restored a stored Fleet Desktop SSO session")
+ setSSOSessionCookie(session.cookie)
+ }
+
+ /// Drops the session everywhere it's held, so the next gated request asks for a fresh sign-in.
+ func signOutSSO() {
+ ssoSessionCookie = nil
+ didRestoreSSOSession = true
+ FleetSSOSessionStore.clear()
+ }
+
+ func resetBackoff() {
+ consecutiveFailures = 0
+ blockedUntil = nil
+ }
+
+ // MARK: - Endpoints
+
+ /// All self-service software titles for this Mac.
+ func selfServiceSoftware() async throws -> [FleetSoftwareTitle] {
+ var titles: [FleetSoftwareTitle] = []
+ let pageSize = 100
+ for page in 0..<20 {
+ let response: FleetSoftwareListResponse = try await get("software", query: [
+ "self_service": "true",
+ "page": String(page),
+ "per_page": String(pageSize),
+ ])
+ titles += response.software
+ if response.meta?.hasNextResults != true || response.software.count < pageSize {
+ break
+ }
+ }
+ return titles
+ }
+
+ func selfServiceCategories() async throws -> [FleetSoftwareCategory] {
+ let response: FleetCategoriesResponse = try await get("software/self_service_categories")
+ return response.selfServiceCategories
+ }
+
+ func install(titleID: Int) async throws {
+ try await send("POST", "software/install/\(titleID)")
+ }
+
+ func uninstall(titleID: Int) async throws {
+ try await send("POST", "software/uninstall/\(titleID)")
+ }
+
+ func installResult(installUUID: String) async throws -> FleetInstallResult? {
+ let response: FleetInstallResultsResponse = try await get("software/install/\(try pathSegment(installUUID))/results")
+ return response.results
+ }
+
+ func uninstallResult(executionID: String) async throws -> FleetScriptResult {
+ try await get("software/uninstall/\(try pathSegment(executionID))/results")
+ }
+
+ func icon(titleID: Int) async throws -> Data {
+ try await request("GET", "software/titles/\(titleID)/icon", gated: true)
+ }
+
+ /// This Mac's host details, including its policies.
+ func deviceHost() async throws -> FleetHost {
+ let response: FleetDeviceHostResponse = try await get("")
+ return response.host
+ }
+
+ func policies() async throws -> [FleetPolicy] {
+ let response: FleetPoliciesResponse = try await get("policies")
+ return response.policies
+ }
+
+ /// Not behind the SSO gate, so it also works before sign-in.
+ func desktopSummary() async throws -> FleetDesktopSummary {
+ try await get("desktop", gated: false)
+ }
+
+ /// Asks Fleet to refresh this Mac's inventory, e.g. after an install.
+ func refetch() async throws {
+ try await send("POST", "refetch")
+ }
+
+ // MARK: - Requests
+
+ private func get(_ path: String, query: [String: String] = [:], gated: Bool = true) async throws -> Response {
+ let data = try await request("GET", path, query: query, gated: gated)
+ do {
+ return try JSONDecoder.fleet.decode(Response.self, from: data)
+ } catch {
+ Logger.shared.logError("Fleet: couldn't decode \(Response.self) from \(path): \(error)")
+ throw FleetError.decoding(String(describing: error))
+ }
+ }
+
+ private func send(_ method: String, _ path: String) async throws {
+ _ = try await request(method, path, gated: true)
+ }
+
+ /// Sends a request, retrying once with a re-read token if Fleet rejects a rotated one.
+ private func request(_ method: String, _ path: String, query: [String: String] = [:], gated: Bool) async throws -> Data {
+ guard let server = FleetDeviceIdentity.serverURL() else { throw FleetError.notConfigured }
+ if gated { restoreSSOSessionIfNeeded() }
+ if gated && isSSORequired { throw FleetError.ssoRequired }
+ if let blockedUntil, blockedUntil > Date() { throw FleetError.backingOff(until: blockedUntil) }
+
+ var retriedWithNewToken = false
+ while true {
+ guard let token = currentToken() else { throw FleetError.notConfigured }
+ let (data, response) = try await perform(method, path, query: query, server: server, token: token.value)
+
+ switch response.statusCode {
+ case 200..<300:
+ consecutiveFailures = 0
+ blockedUntil = nil
+ return data
+
+ case 401:
+ let body = try? JSONDecoder.fleet.decode(FleetErrorResponse.self, from: data)
+ if body?.ssoRequired == true {
+ Logger.shared.logDebug("Fleet: \(method) \(path) requires Fleet Desktop SSO")
+ isSSORequired = true
+ // Whatever session was being sent is spent, so it isn't kept for the next launch
+ signOutSSO()
+ throw FleetError.ssoRequired
+ }
+ // The token may have rotated since it was read; re-read and retry exactly once
+ if !retriedWithNewToken, reloadToken() != token {
+ retriedWithNewToken = true
+ continue
+ }
+ registerFailure("\(method) \(path) returned 401")
+ throw FleetError.unauthorized
+
+ case 429:
+ registerFailure("\(method) \(path) returned 429", minimum: Self.maximumBackoff / 2)
+ throw FleetError.rateLimited
+
+ case 404:
+ // Something that doesn't exist, e.g. an old install result, not a server problem, so no backoff
+ Logger.shared.logDebug("Fleet: \(method) \(path) returned 404")
+ let message = (try? JSONDecoder.fleet.decode(FleetErrorResponse.self, from: data))?.summary ?? ""
+ throw FleetError.server(status: 404, message: message)
+
+ default:
+ let message = (try? JSONDecoder.fleet.decode(FleetErrorResponse.self, from: data))?.summary ?? ""
+ registerFailure("\(method) \(path) returned \(response.statusCode) \(message)")
+ throw FleetError.server(status: response.statusCode, message: message)
+ }
+ }
+ }
+
+ private func perform(_ method: String, _ path: String, query: [String: String], server: URL, token: String) async throws -> (Data, HTTPURLResponse) {
+ var components = URLComponents(url: server, resolvingAgainstBaseURL: false)
+ var basePath = components?.path ?? ""
+ while basePath.hasSuffix("/") { basePath.removeLast() }
+ // The host endpoint is the device path itself, without a trailing slash
+ components?.path = "\(basePath)/api/v1/fleet/device/\(token)" + (path.isEmpty ? "" : "/\(path)")
+ if !query.isEmpty {
+ components?.queryItems = query.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) }
+ }
+ guard let url = components?.url else { throw FleetError.notConfigured }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = method
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+ if let ssoSessionCookie {
+ request.setValue("\(Self.ssoSessionCookieName)=\(ssoSessionCookie)", forHTTPHeaderField: "Cookie")
+ }
+
+ do {
+ let (data, response) = try await session.data(for: request)
+ guard let http = response as? HTTPURLResponse else {
+ throw FleetError.network("Invalid response from Fleet")
+ }
+ Logger.shared.logDebug("Fleet: \(method) \(path) -> \(http.statusCode)")
+ return (data, http)
+ } catch let error as FleetError {
+ throw error
+ } catch {
+ // The token is part of the URL, so never log the URL itself
+ registerFailure("\(method) \(path) failed: \((error as NSError).localizedDescription)")
+ throw FleetError.network((error as NSError).localizedDescription)
+ }
+ }
+
+ /// Ids from Fleet's responses go into request paths, so they must not contain URL syntax.
+ private func pathSegment(_ id: String) throws -> String {
+ guard FleetDeviceIdentity.isValidToken(id) else {
+ throw FleetError.decoding("Invalid id from Fleet")
+ }
+ return id
+ }
+
+ // MARK: - Token and backoff
+
+ private func currentToken() -> FleetDeviceIdentity.Token? {
+ if let token, FleetDeviceIdentity.tokenModificationDate() == token.modified {
+ return token
+ }
+ return reloadToken()
+ }
+
+ @discardableResult
+ private func reloadToken() -> FleetDeviceIdentity.Token? {
+ token = FleetDeviceIdentity.readToken()
+ return token
+ }
+
+ private func registerFailure(_ reason: String, minimum: TimeInterval = FleetClient.minimumBackoff) {
+ consecutiveFailures += 1
+ let delay = min(max(minimum, Self.minimumBackoff * pow(2, Double(consecutiveFailures - 1))), Self.maximumBackoff)
+ blockedUntil = Date().addingTimeInterval(delay)
+ Logger.shared.logError("Fleet: \(reason); pausing requests for \(Int(delay))s")
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetDeviceIdentity.swift b/SupportCompanion/Services/Fleet/FleetDeviceIdentity.swift
new file mode 100644
index 0000000..88f84b0
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetDeviceIdentity.swift
@@ -0,0 +1,121 @@
+//
+// FleetDeviceIdentity.swift
+// SupportCompanion
+//
+// Finds the Fleet server and this device's token, as installed by Fleet's agent (orbit).
+// Mirrors how Fleet's own macOS Fleet Desktop app (apps/fleet-desktop-macos) resolves them.
+//
+
+import Foundation
+
+enum FleetDeviceIdentity {
+ /// Written by orbit, world-readable, rotated about every hour.
+ static let tokenPath = "/opt/orbit/identifier"
+ /// Fleet URL on Macs where Fleet is the MDM, delivered in a configuration profile.
+ static let fleetdConfigPlistPath = "/Library/Managed Preferences/com.fleetdm.fleetd.config.plist"
+ /// Fleet URL when orbit was installed from a package built with `fleetctl package`.
+ static let orbitDaemonPlistPath = "/Library/LaunchDaemons/com.fleetdm.orbit.plist"
+ static let logFolder = "/var/log/orbit"
+
+ static var isOrbitInstalled: Bool {
+ FileManager.default.fileExists(atPath: tokenPath)
+ }
+
+ /// The Fleet server URL.
+ ///
+ /// The device token is sent to this server, so it only comes from sources a standard user can't change,
+ /// in order: the `FleetUrl` preference when set by an administrator (see TrustedPreferences), the
+ /// `FleetURL` in fleetd's MDM configuration profile, or the `ORBIT_FLEET_URL` in orbit's LaunchDaemon.
+ static func serverURL() -> URL? {
+ for candidate in serverURLCandidates() {
+ if let url = validServerURL(candidate) {
+ return url
+ }
+ }
+ return nil
+ }
+
+ struct Token: Equatable, Sendable {
+ let value: String
+ let modified: Date
+ }
+
+ /// Reads the current device token, or nil when orbit hasn't written a valid one.
+ static func readToken() -> Token? {
+ guard let attributes = try? FileManager.default.attributesOfItem(atPath: tokenPath),
+ let modified = attributes[.modificationDate] as? Date,
+ let contents = FileManager.default.contents(atPath: tokenPath),
+ let value = String(data: contents, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
+ isValidToken(value) else {
+ return nil
+ }
+ return Token(value: value, modified: modified)
+ }
+
+ /// Modification date of the token file, to detect rotation without reading it.
+ static func tokenModificationDate() -> Date? {
+ (try? FileManager.default.attributesOfItem(atPath: tokenPath))?[.modificationDate] as? Date
+ }
+
+ /// Why Fleet can't be used on this Mac, or nil when it can. Safe to log and show: it never includes the token.
+ static func configurationProblem() -> String? {
+ if !isOrbitInstalled {
+ return "Fleet's agent (orbit) isn't installed: \(tokenPath) doesn't exist."
+ }
+ if readToken() == nil {
+ return "The device token at \(tokenPath) couldn't be read or isn't valid."
+ }
+ let candidates = serverURLCandidates()
+ if candidates.isEmpty {
+ return "No Fleet server URL found in \(fleetdConfigPlistPath) (FleetURL) or \(orbitDaemonPlistPath) (ORBIT_FLEET_URL)."
+ }
+ if serverURL() == nil {
+ return "The configured Fleet server URL must be a valid HTTPS URL."
+ }
+ return nil
+ }
+
+ // MARK: - Private
+
+ /// Configured URLs in priority order, before validation.
+ private static func serverURLCandidates() -> [String] {
+ var candidates: [String] = []
+
+ let override = TrustedPreferences.string(forKey: "FleetUrl", default: "")
+ if !override.isEmpty {
+ candidates.append(override)
+ }
+
+ if let config = NSDictionary(contentsOfFile: fleetdConfigPlistPath),
+ let url = config["FleetURL"] as? String, !url.isEmpty {
+ candidates.append(url)
+ }
+
+ if let plist = NSDictionary(contentsOfFile: orbitDaemonPlistPath),
+ let environment = plist["EnvironmentVariables"] as? [String: Any],
+ let url = environment["ORBIT_FLEET_URL"] as? String, !url.isEmpty {
+ candidates.append(url)
+ }
+
+ return candidates
+ }
+
+ private static func validServerURL(_ string: String) -> URL? {
+ let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard let url = URL(string: trimmed),
+ url.scheme?.lowercased() == "https",
+ let host = url.host, !host.isEmpty else {
+ return nil
+ }
+ return url
+ }
+
+ /// ASCII letters, digits, `-` and `_` only, so a token can't add path segments or other URL syntax
+ /// to the device URLs built from it.
+ private static let tokenCharacters = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
+
+ /// Also used for other ids Fleet returns that go into request paths.
+ static func isValidToken(_ token: String) -> Bool {
+ !token.isEmpty && token.unicodeScalars.allSatisfy { tokenCharacters.contains($0) }
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetIconCache.swift b/SupportCompanion/Services/Fleet/FleetIconCache.swift
new file mode 100644
index 0000000..a399411
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetIconCache.swift
@@ -0,0 +1,162 @@
+//
+// FleetIconCache.swift
+// SupportCompanion
+//
+
+import AppKit
+import CryptoKit
+import Foundation
+
+/// Icons for Fleet software titles, in order of preference:
+/// 1. The custom or App Store icon Fleet has for the title (`icon_url`), cached on disk.
+/// 2. The icon of the installed app, or the one saved the last time it was seen installed.
+/// 3. The icon Fleet's own web pages would show, from Fleet's icon set on GitHub (see FleetIconCatalog).
+///
+/// Only titles with an `icon_url` are requested from Fleet, since a request for a title without an icon
+/// would fail.
+@MainActor
+final class FleetIconCache {
+ static let shared = FleetIconCache()
+
+ private var memory: [String: NSImage] = [:]
+ private var inFlight: [String: Task] = [:]
+ private var failedKeys: Set = []
+ private let directory: URL? = FileManager.default
+ .urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
+ .appendingPathComponent("SupportCompanion/FleetIcons", isDirectory: true)
+
+ /// Icon available without a network request, for the first render.
+ func cachedIcon(for title: FleetSoftwareTitle) -> NSImage? {
+ if let key = cacheKey(for: title), let image = storedImage(forKey: key) {
+ return image
+ }
+ return installedAppIcon(for: title) ?? storedImage(forKey: catalogKey(for: title))
+ }
+
+ func icon(for title: FleetSoftwareTitle) async -> NSImage? {
+ if let key = cacheKey(for: title), let image = await download(key: key, { try? await FleetClient.shared.icon(titleID: title.id) }) {
+ return image
+ }
+ if let image = installedAppIcon(for: title) {
+ return image
+ }
+ return await download(key: catalogKey(for: title)) {
+ guard let url = await FleetIconCatalog.shared.iconURL(for: title) else { return nil }
+ return await FleetIconCatalog.shared.download(url)
+ }
+ }
+
+ /// Returns the stored image for `key`, or downloads and stores it. A key that failed isn't retried this session.
+ private func download(key: String, _ fetch: @escaping @MainActor () async -> Data?) async -> NSImage? {
+ if let image = storedImage(forKey: key) { return image }
+ if failedKeys.contains(key) { return nil }
+ if let task = inFlight[key] { return await task.value }
+
+ let task = Task { [directory] in
+ guard let data = await fetch(), let image = NSImage(data: data) else { return nil }
+ if let directory {
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try? data.write(to: directory.appendingPathComponent("\(key).png"))
+ }
+ return image
+ }
+ inFlight[key] = task
+ let image = await task.value
+ inFlight[key] = nil
+ if let image {
+ memory[key] = image
+ } else {
+ failedKeys.insert(key)
+ }
+ return image
+ }
+
+ private func storedImage(forKey key: String) -> NSImage? {
+ if let image = memory[key] { return image }
+ if let url = fileURL(forKey: key), let image = NSImage(contentsOf: url) {
+ memory[key] = image
+ return image
+ }
+ return nil
+ }
+
+ private func catalogKey(for title: FleetSoftwareTitle) -> String {
+ "github-\(title.id)"
+ }
+
+ /// Changes when the icon on Fleet changes. The device token in `icon_url` rotates, so it's left out.
+ private func cacheKey(for title: FleetSoftwareTitle) -> String? {
+ guard let iconUrl = title.iconUrl, !iconUrl.isEmpty else { return nil }
+ let withoutToken = iconUrl.replacingOccurrences(of: #"/device/[^/]+/"#, with: "/device/-/", options: .regularExpression)
+ let hash = SHA256.hash(data: Data(withoutToken.utf8)).prefix(8).map { String(format: "%02x", $0) }.joined()
+ return "\(title.id)-\(hash)"
+ }
+
+ private func fileURL(forKey key: String) -> URL? {
+ guard let url = directory?.appendingPathComponent("\(key).png"),
+ FileManager.default.fileExists(atPath: url.path) else { return nil }
+ return url
+ }
+
+ /// The icon of the app on this Mac, found by Fleet's installed paths, bundle identifier, or name.
+ /// Fleet's web page shows icons bundled with its front end for titles without a custom icon, which
+ /// the device API doesn't expose, so this is the only other source.
+ private func installedAppIcon(for title: FleetSoftwareTitle) -> NSImage? {
+ let savedKey = "app-\(title.id)"
+ if let path = installedAppPath(for: title) {
+ let icon = NSWorkspace.shared.icon(forFile: path)
+ if memory[savedKey] == nil {
+ memory[savedKey] = icon
+ saveAppIcon(icon, key: savedKey)
+ }
+ return icon
+ }
+ return storedImage(forKey: savedKey)
+ }
+
+ /// Keeps an installed app's icon so the title still has one after the app is uninstalled.
+ private func saveAppIcon(_ icon: NSImage, key: String) {
+ guard let directory else { return }
+ let size = NSSize(width: 128, height: 128)
+ guard let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: Int(size.width), pixelsHigh: Int(size.height),
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0
+ ) else { return }
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: bitmap)
+ icon.draw(in: NSRect(origin: .zero, size: size))
+ NSGraphicsContext.restoreGraphicsState()
+ guard let data = bitmap.representation(using: .png, properties: [:]) else { return }
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try? data.write(to: directory.appendingPathComponent("\(key).png"))
+ }
+
+ private func installedAppPath(for title: FleetSoftwareTitle) -> String? {
+ let fileManager = FileManager.default
+ let paths = (title.installedVersions ?? []).flatMap { $0.installedPaths ?? [] }
+ if let path = paths.first(where: { fileManager.fileExists(atPath: $0) }) {
+ return path
+ }
+
+ let bundleIDs = ([title.bundleIdentifier] + (title.installedVersions ?? []).map(\.bundleIdentifier))
+ .compactMap { $0 }.filter { !$0.isEmpty }
+ for bundleID in bundleIDs {
+ if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {
+ return url.path
+ }
+ }
+
+ // Titles for macOS apps are named after the app bundle, e.g. "Audacity" or "Audacity.app"
+ for name in Set([title.name, title.title]) where !name.isEmpty && !name.contains("/") {
+ let bundleName = name.hasSuffix(".app") ? name : "\(name).app"
+ for folder in ["/Applications", "/Applications/Utilities", NSHomeDirectory() + "/Applications"] {
+ let path = "\(folder)/\(bundleName)"
+ if fileManager.fileExists(atPath: path) {
+ return path
+ }
+ }
+ }
+ return nil
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetIconCatalog.swift b/SupportCompanion/Services/Fleet/FleetIconCatalog.swift
new file mode 100644
index 0000000..c51aed0
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetIconCatalog.swift
@@ -0,0 +1,167 @@
+//
+// FleetIconCatalog.swift
+// SupportCompanion
+//
+// Icons for titles Fleet has no icon for, from the icon set Fleet's own web pages use.
+//
+// Fleet's My Device page doesn't get these icons from the API: it matches the software name against
+// icons compiled into the page (frontend/pages/SoftwarePage/components/icons/index.ts in fleetdm/fleet).
+// This reads that index from GitHub, matches names the way Fleet does, and downloads only the icons of
+// titles in this Mac's catalog. Icons Fleet draws in code rather than as PNGs (e.g. Slack) aren't covered.
+// Turn it off with `FleetIconsFromGitHub = false`.
+//
+
+import AppKit
+import Foundation
+
+@MainActor
+final class FleetIconCatalog {
+ static let shared = FleetIconCatalog()
+
+ private static let base = "https://raw.githubusercontent.com/fleetdm/fleet/main/frontend/pages/SoftwarePage/components/icons/"
+ private static let indexRefreshInterval: TimeInterval = 7 * 24 * 3600
+
+ private struct Index: Codable {
+ let fetchedAt: Date
+ /// Lowercased name prefix → PNG file name.
+ let files: [String: String]
+ }
+
+ private let session: URLSession
+ private let directory: URL?
+ private var index: Index?
+ private var indexTask: Task?
+ private var indexUnavailable = false
+
+ init() {
+ let configuration = URLSessionConfiguration.ephemeral
+ configuration.timeoutIntervalForRequest = 20
+ configuration.httpCookieStorage = nil
+ session = URLSession(configuration: configuration)
+ directory = FileManager.default
+ .urls(for: .applicationSupportDirectory, in: .userDomainMask).first?
+ .appendingPathComponent("SupportCompanion/FleetIcons", isDirectory: true)
+ }
+
+ static var isEnabled: Bool {
+ DefaultsStore.value(forKey: "FleetIconsFromGitHub", default: true)
+ }
+
+ /// The PNG to download for a title, or nil when Fleet's icon set has none for it.
+ func iconURL(for title: FleetSoftwareTitle) async -> URL? {
+ guard Self.isEnabled, let index = await loadIndex() else { return nil }
+ let names = [title.name, title.title].map(Self.normalized)
+ for name in names {
+ if let file = Self.match(name, in: index.files),
+ let encoded = file.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) {
+ return URL(string: Self.base + "png/" + encoded)
+ }
+ }
+ return nil
+ }
+
+ func download(_ url: URL) async -> Data? {
+ guard let (data, response) = try? await session.data(from: url),
+ (response as? HTTPURLResponse)?.statusCode == 200 else { return nil }
+ return data
+ }
+
+ // MARK: - Matching
+
+ /// Like Fleet's `matchLoosePrefixToKey`: the longest key that is the whole name or a prefix followed by a space.
+ static func match(_ name: String, in files: [String: String]) -> String? {
+ guard !name.isEmpty else { return nil }
+ let key = files.keys
+ .filter { name == $0 || name.hasPrefix($0 + " ") }
+ .max { $0.count < $1.count }
+ return key.flatMap { files[$0] }
+ }
+
+ private static func normalized(_ name: String) -> String {
+ var name = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
+ if name.hasSuffix(".app") { name.removeLast(4) }
+ return name
+ }
+
+ /// Reads `SOFTWARE_NAME_TO_ICON_MAP` and the PNG imports it refers to from Fleet's icons/index.ts.
+ static func parseIndex(_ source: String) -> [String: String] {
+ var pngByVariable: [String: String] = [:]
+ // NSRegularExpression rather than regex literals, which older Xcode versions only accept with a
+ // compiler flag in Swift 5 mode
+ guard let importPattern = try? NSRegularExpression(
+ pattern: #"^import\s+(\w+)\s+from\s+"\./png/([A-Za-z0-9._@+\-]+\.png)";$"#),
+ let entryPattern = try? NSRegularExpression(
+ pattern: #"^\s*(?:"([^"]+)"|([A-Za-z0-9_]+))\s*:\s*(\w+)\s*,?\s*$"#) else {
+ return [:]
+ }
+
+ var files: [String: String] = [:]
+ var inMap = false
+ for line in source.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) {
+ if let groups = captures(of: importPattern, in: line) {
+ if let variable = groups[0], let file = groups[1] {
+ pngByVariable[variable] = file
+ }
+ } else if line.hasPrefix("export const SOFTWARE_NAME_TO_ICON_MAP") {
+ inMap = true
+ } else if inMap {
+ if line.hasPrefix("}") { break }
+ if let groups = captures(of: entryPattern, in: line),
+ let variable = groups[2], let file = pngByVariable[variable] {
+ let key = (groups[0] ?? groups[1] ?? "").trimmingCharacters(in: .whitespaces).lowercased()
+ if !key.isEmpty { files[key] = file }
+ }
+ }
+ }
+ return files
+ }
+
+ /// The capture groups of a match covering the whole line, or nil if the line doesn't match.
+ private static func captures(of pattern: NSRegularExpression, in line: String) -> [String?]? {
+ let range = NSRange(line.startIndex..., in: line)
+ guard let match = pattern.firstMatch(in: line, range: range), match.range == range else { return nil }
+ return (1.. Index? {
+ if let index, Date().timeIntervalSince(index.fetchedAt) < Self.indexRefreshInterval { return index }
+ if index == nil, let indexFile, let data = try? Data(contentsOf: indexFile),
+ let saved = try? JSONDecoder().decode(Index.self, from: data) {
+ index = saved
+ if Date().timeIntervalSince(saved.fetchedAt) < Self.indexRefreshInterval { return saved }
+ }
+ // Try GitHub once per launch at most; a stale index is still better than none
+ guard !indexUnavailable else { return index }
+ if let indexTask { return await indexTask.value }
+
+ let task = Task { [session] in
+ guard let url = URL(string: Self.base + "index.ts"),
+ let (data, response) = try? await session.data(from: url),
+ (response as? HTTPURLResponse)?.statusCode == 200,
+ let source = String(data: data, encoding: .utf8) else { return nil }
+ let files = Self.parseIndex(source)
+ return files.isEmpty ? nil : Index(fetchedAt: Date(), files: files)
+ }
+ indexTask = task
+ let fetched = await task.value
+ indexTask = nil
+
+ guard let fetched else {
+ Logger.shared.logDebug("Fleet: couldn't load Fleet's icon index from GitHub")
+ indexUnavailable = true
+ return index
+ }
+ index = fetched
+ if let directory, let indexFile, let data = try? JSONEncoder().encode(fetched) {
+ try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ try? data.write(to: indexFile)
+ }
+ return fetched
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetModels.swift b/SupportCompanion/Services/Fleet/FleetModels.swift
new file mode 100644
index 0000000..4ba0809
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetModels.swift
@@ -0,0 +1,307 @@
+//
+// FleetModels.swift
+// SupportCompanion
+//
+// Response types for Fleet's device-authenticated API (/api/v1/fleet/device/{token}/…).
+// Decoded with `.convertFromSnakeCase`, so property names are the camel-cased JSON keys.
+//
+
+import Foundation
+
+// MARK: - Software
+
+enum FleetInstallStatus: String, Decodable, Sendable {
+ case installed
+ case pendingInstall = "pending_install"
+ case failedInstall = "failed_install"
+ case pendingUninstall = "pending_uninstall"
+ case failedUninstall = "failed_uninstall"
+ case unknown
+
+ init(from decoder: Decoder) throws {
+ let raw = try decoder.singleValueContainer().decode(String.self)
+ self = FleetInstallStatus(rawValue: raw) ?? .unknown
+ }
+}
+
+struct FleetInstalledVersion: Decodable, Equatable, Sendable {
+ let version: String
+ let bundleIdentifier: String?
+ let installedPaths: [String]?
+}
+
+struct FleetLastInstall: Decodable, Equatable, Sendable {
+ let installUuid: String?
+ let commandUuid: String?
+ let installedAt: Date?
+}
+
+struct FleetLastUninstall: Decodable, Equatable, Sendable {
+ let scriptExecutionId: String?
+ let uninstalledAt: Date?
+}
+
+/// A software package or App Store app that Fleet can install for a title.
+struct FleetInstaller: Decodable, Equatable, Sendable {
+ let name: String?
+ let version: String?
+ let platform: String?
+ let selfService: Bool?
+ let categories: [String]?
+ let lastInstall: FleetLastInstall?
+ let lastUninstall: FleetLastUninstall?
+ let hasUninstallScript: Bool?
+ let appStoreId: String?
+}
+
+struct FleetSoftwareTitle: Decodable, Identifiable, Equatable, Sendable {
+ let id: Int
+ let name: String
+ let displayName: String?
+ let bundleIdentifier: String?
+ let iconUrl: String?
+ let source: String?
+ let status: FleetInstallStatus?
+ /// Set with a `failed_install` status when Fleet skipped a patch-when-closed install because the app was open.
+ let skippedInstall: Bool?
+ let installedVersions: [FleetInstalledVersion]?
+ let softwarePackage: FleetInstaller?
+ let appStoreApp: FleetInstaller?
+
+ enum Action: Equatable, Sendable {
+ case install
+ case update
+ case reinstall
+ case uninstall
+ }
+
+ var title: String {
+ if let displayName, !displayName.isEmpty { return displayName }
+ return name
+ }
+
+ var installer: FleetInstaller? { softwarePackage ?? appStoreApp }
+
+ var bundleIdentifiers: [String] {
+ ([bundleIdentifier] + (installedVersions ?? []).map(\.bundleIdentifier)).compactMap { $0 }.filter { !$0.isEmpty }
+ }
+
+ var categories: [String] { installer?.categories ?? [] }
+
+ /// Highest installed version, if any version is installed.
+ var installedVersion: String? {
+ installedVersions?
+ .map(\.version)
+ .filter { !$0.isEmpty }
+ .max { FleetVersion.isOlder($0, than: $1) }
+ }
+
+ var availableVersion: String? {
+ guard let version = installer?.version, !version.isEmpty else { return nil }
+ return version
+ }
+
+ var isInstalled: Bool {
+ status == .installed || installedVersion != nil
+ }
+
+ /// Whether the installed version is older than Fleet's, including while the update is being installed.
+ var isUpdateAvailable: Bool {
+ guard let installedVersion, let availableVersion else { return false }
+ return FleetVersion.isOlder(installedVersion, than: availableVersion)
+ }
+
+ var isPending: Bool {
+ status == .pendingInstall || status == .pendingUninstall
+ }
+
+ var hasFailed: Bool {
+ status == .failedInstall || status == .failedUninstall
+ }
+
+ /// Fleet generates uninstall scripts for packages; App Store apps can't be uninstalled from self-service.
+ var canUninstall: Bool {
+ isInstalled && softwarePackage != nil && softwarePackage?.hasUninstallScript != false
+ }
+
+ /// Installing the same version again, as Fleet's self-service page offers for installed titles.
+ var canReinstall: Bool {
+ isInstalled && !isUpdateAvailable && installer != nil
+ }
+
+ /// Whether the title is one of the keys in a preference: its id as a string, or its display name or
+ /// name in any case.
+ func matches(_ key: String) -> Bool {
+ let key = key.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !key.isEmpty else { return false }
+ if key == String(id) { return true }
+ return [displayName, name].contains { $0.map { $0.caseInsensitiveCompare(key) == .orderedSame } ?? false }
+ }
+
+ /// The main action offered for this title, or nil while an action is pending.
+ var primaryAction: Action? {
+ guard !isPending, installer != nil else { return nil }
+ if isUpdateAvailable { return .update }
+ if !isInstalled { return .install }
+ return nil
+ }
+}
+
+struct FleetSoftwareCategory: Decodable, Identifiable, Equatable, Sendable {
+ let id: Int
+ let name: String
+}
+
+// MARK: - Results
+
+struct FleetInstallResult: Decodable, Sendable {
+ let installUuid: String?
+ let softwareTitle: String?
+ let softwareTitleId: Int?
+ let status: FleetInstallStatus?
+ let output: String?
+ let preInstallQueryOutput: String?
+ let postInstallScriptOutput: String?
+}
+
+/// Result of an uninstall script run.
+struct FleetScriptResult: Decodable, Sendable {
+ let executionId: String?
+ let output: String?
+ let message: String?
+ let exitCode: Int?
+}
+
+// MARK: - Policies
+
+struct FleetPolicy: Decodable, Identifiable, Equatable, Sendable {
+ let id: Int
+ let name: String
+ let description: String?
+ let resolution: String?
+ let critical: Bool?
+ /// "pass", "fail", or empty when the policy hasn't run on this host yet.
+ let response: String?
+
+ var isFailing: Bool { response == "fail" }
+}
+
+// MARK: - Host
+
+/// This Mac's record in Fleet, from `GET /device/{token}`.
+struct FleetHost: Decodable, Equatable, Sendable {
+ let id: Int
+ let hostname: String?
+ let computerName: String?
+ let seenTime: Date?
+ let detailUpdatedAt: Date?
+ let policyUpdatedAt: Date?
+ let lastEnrolledAt: Date?
+ let teamName: String?
+ /// Newer Fleet versions call teams fleets.
+ let fleetName: String?
+ let orbitVersion: String?
+ let osqueryVersion: String?
+ let fleetDesktopVersion: String?
+ /// Set after a refetch until the host has sent fresh details.
+ let refetchRequested: Bool?
+ let policies: [FleetPolicy]?
+
+ var team: String? { [teamName, fleetName].compactMap { $0 }.first { !$0.isEmpty } }
+
+ /// Fleet sends Go's zero time (year 1) for events that haven't happened.
+ static func realDate(_ date: Date?) -> Date? {
+ guard let date, date > Date(timeIntervalSince1970: 0) else { return nil }
+ return date
+ }
+}
+
+struct FleetDeviceHostResponse: Decodable, Sendable {
+ let host: FleetHost
+}
+
+// MARK: - Desktop summary
+
+struct FleetDesktopSummary: Decodable, Sendable {
+ let failingPoliciesCount: Int?
+ let selfService: Bool?
+}
+
+// MARK: - Response envelopes
+
+struct FleetSoftwareListResponse: Decodable, Sendable {
+ struct Meta: Decodable, Sendable {
+ let hasNextResults: Bool?
+ }
+
+ let software: [FleetSoftwareTitle]
+ let count: Int?
+ let meta: Meta?
+}
+
+struct FleetCategoriesResponse: Decodable, Sendable {
+ let selfServiceCategories: [FleetSoftwareCategory]
+}
+
+struct FleetInstallResultsResponse: Decodable, Sendable {
+ let results: FleetInstallResult?
+}
+
+struct FleetPoliciesResponse: Decodable, Sendable {
+ let policies: [FleetPolicy]
+}
+
+/// Where Fleet wants the user sent to sign in.
+struct FleetSSOInitiationResponse: Decodable, Sendable {
+ let url: String
+}
+
+/// Fleet's error body. `ssoRequired` is set when Fleet Desktop SSO is enabled and no session cookie was sent.
+struct FleetErrorResponse: Decodable, Sendable {
+ struct Detail: Decodable, Sendable {
+ let name: String?
+ let reason: String?
+ }
+
+ let message: String?
+ let errors: [Detail]?
+ let ssoRequired: Bool?
+
+ var summary: String {
+ let reasons = (errors ?? []).compactMap(\.reason).filter { !$0.isEmpty }
+ return reasons.isEmpty ? (message ?? "") : reasons.joined(separator: " ")
+ }
+}
+
+// MARK: - Versions
+
+enum FleetVersion {
+ /// Numeric-aware comparison, so "4.9" is older than "4.10".
+ static func isOlder(_ lhs: String, than rhs: String) -> Bool {
+ lhs.compare(rhs, options: .numeric) == .orderedAscending
+ }
+}
+
+// MARK: - Decoding
+
+extension JSONDecoder {
+ static let fleet: JSONDecoder = {
+ let decoder = JSONDecoder()
+ decoder.keyDecodingStrategy = .convertFromSnakeCase
+ decoder.dateDecodingStrategy = .custom { decoder in
+ let string = try decoder.singleValueContainer().decode(String.self)
+ let fractional = ISO8601DateFormatter()
+ fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ if let date = fractional.date(from: string) ?? ISO8601DateFormatter().date(from: string) {
+ return date
+ }
+ // Go can send more fractional digits than ISO8601DateFormatter reads
+ let trimmed = string.replacingOccurrences(of: #"\.\d+"#, with: "", options: .regularExpression)
+ if let date = ISO8601DateFormatter().date(from: trimmed) {
+ return date
+ }
+ throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Invalid date: \(string)"))
+ }
+ return decoder
+ }()
+}
diff --git a/SupportCompanion/Services/Fleet/FleetRecommendedApps.swift b/SupportCompanion/Services/Fleet/FleetRecommendedApps.swift
new file mode 100644
index 0000000..c817362
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetRecommendedApps.swift
@@ -0,0 +1,45 @@
+//
+// FleetRecommendedApps.swift
+// SupportCompanion
+//
+// Apps IT highlights at the top of the Fleet catalog, whether or not they're installed, from the `FleetRecommendedApps` preference: an
+// array of software title ids (as strings), display names or names, in the order to show them.
+// `FleetRecommendedTitle` renames the section.
+//
+// FleetRecommendedApps
+//
+// Slack
+// 42
+//
+// FleetRecommendedTitle
+// Start here
+//
+
+import Foundation
+
+struct FleetRecommendedApps {
+ let keys: [String]
+ let sectionTitle: String
+
+ init(keys: [String]?, sectionTitle: String? = nil) {
+ self.keys = keys ?? []
+ let trimmed = sectionTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ self.sectionTitle = trimmed.isEmpty ? Constants.Fleet.recommended : trimmed
+ }
+
+ @MainActor static var current: FleetRecommendedApps {
+ FleetRecommendedApps(
+ keys: DefaultsStore.optionalValue(forKey: "FleetRecommendedApps"),
+ sectionTitle: DefaultsStore.optionalValue(forKey: "FleetRecommendedTitle")
+ )
+ }
+
+ /// The recommended titles among `titles`, in the preference's order.
+ func titles(from titles: [FleetSoftwareTitle]) -> [FleetSoftwareTitle] {
+ var seen: Set = []
+ return keys.compactMap { key in
+ guard let title = titles.first(where: { $0.matches(key) }), seen.insert(title.id).inserted else { return nil }
+ return title
+ }
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetRunningApps.swift b/SupportCompanion/Services/Fleet/FleetRunningApps.swift
new file mode 100644
index 0000000..fecc632
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetRunningApps.swift
@@ -0,0 +1,86 @@
+//
+// FleetRunningApps.swift
+// SupportCompanion
+//
+// Which Fleet titles are open on this Mac, for installs that can't run while their app is open.
+//
+
+import AppKit
+import Observation
+
+@MainActor
+@Observable
+final class FleetRunningApps {
+ static let shared = FleetRunningApps()
+
+ private(set) var runningBundleIDs: Set = []
+
+ @ObservationIgnored private var observers: [NSObjectProtocol] = []
+
+ private init() {
+ update()
+ let center = NSWorkspace.shared.notificationCenter
+ for name in [
+ NSWorkspace.didLaunchApplicationNotification,
+ NSWorkspace.didTerminateApplicationNotification,
+ ] {
+ observers.append(
+ center.addObserver(forName: name, object: nil, queue: .main) { _ in
+ MainActor.assumeIsolated { FleetRunningApps.shared.update() }
+ })
+ }
+ }
+
+ func isRunning(_ title: FleetSoftwareTitle) -> Bool {
+ !runningBundleIDs.isDisjoint(with: title.bundleIdentifiers)
+ }
+
+ /// Asks the title's app to quit, as if the user chose Quit, and waits up to `timeout` for it to exit.
+ /// Returns false if it's still open, e.g. because the user cancelled a prompt to save changes, or if
+ /// Fleet doesn't know the app's bundle identifier, so it can't be found.
+ func quit(_ title: FleetSoftwareTitle, timeout: TimeInterval = 30) async -> Bool {
+ guard !title.bundleIdentifiers.isEmpty else { return false }
+ let apps = title.bundleIdentifiers.flatMap {
+ NSRunningApplication.runningApplications(withBundleIdentifier: $0)
+ }
+ apps.forEach { $0.terminate() }
+ let deadline = Date().addingTimeInterval(timeout)
+ while apps.contains(where: { !$0.isTerminated }), Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(500))
+ }
+ update()
+ return apps.allSatisfy(\.isTerminated)
+ }
+
+ private func update() {
+ let ids = Set(NSWorkspace.shared.runningApplications.compactMap(\.bundleIdentifier))
+ if ids != runningBundleIDs {
+ runningBundleIDs = ids
+ }
+ }
+}
+
+/// Recognizes install script output that says the app has to be closed first.
+///
+/// Fleet-maintained apps with "patch when closed" are flagged by Fleet itself. Custom packages can only
+/// say so in their install script's output, so this matches that output against phrases, case-insensitively.
+/// `FleetAppOpenMessages` (array of strings) replaces the built-in phrases, e.g. with the exact message your
+/// scripts print; an empty array turns detection off for custom packages.
+struct FleetAppOpenMessages {
+ static let defaults = [
+ "must be closed", "must be quit", "needs to be closed", "needs to be quit",
+ "app is running", "app is open", "app was open",
+ "quit the app", "close the app",
+ ]
+
+ let phrases: [String]
+
+ @MainActor static var current: FleetAppOpenMessages {
+ FleetAppOpenMessages(
+ phrases: DefaultsStore.optionalValue(forKey: "FleetAppOpenMessages") ?? defaults)
+ }
+
+ func matches(_ output: String) -> Bool {
+ phrases.contains { !$0.isEmpty && output.localizedCaseInsensitiveContains($0) }
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetSSOController.swift b/SupportCompanion/Services/Fleet/FleetSSOController.swift
new file mode 100644
index 0000000..9bbed0c
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetSSOController.swift
@@ -0,0 +1,215 @@
+//
+// FleetSSOController.swift
+// SupportCompanion
+//
+// Drives Fleet Desktop SSO sign-in (Fleet 4.92 and later).
+//
+// The flow, all of it Fleet's:
+// 1. POST /device/{token}/sso answers with the identity provider's URL and sets a handshake cookie.
+// 2. The user signs in at the identity provider, which posts the SAML assertion back to Fleet.
+// 3. Fleet's callback sets __Host-FLEET_DESKTOP_SESSION and redirects to this Mac's device page.
+//
+// Steps 1 and 3 happen on Fleet, step 2 at the identity provider, so the sign-in runs in a web view
+// rather than in the app: the assertion is between the user and their identity provider, and this
+// app only ever sees the session cookie at the end of it. That cookie is HttpOnly, so it's read from
+// the web view's cookie store rather than from the page.
+//
+
+import Foundation
+import Observation
+import WebKit
+
+@MainActor
+@Observable
+final class FleetSSOController: NSObject {
+ enum Phase: Equatable {
+ /// Nothing in flight.
+ case idle
+ /// Asking Fleet where to send the user.
+ case starting
+ /// The identity provider's page is up and it's the user's turn.
+ case signingIn
+ /// Fleet handed back a session and it's being stored.
+ case completing
+ case failed(String)
+ }
+
+ private(set) var phase: Phase = .idle
+ /// Whether the sign-in sheet is on screen.
+ var isPresented = false
+ private(set) var webView: WKWebView?
+
+ /// Called once a sign-in succeeds, so the Fleet pages reload what they couldn't fetch before.
+ @ObservationIgnored var onSignedIn: (() -> Void)?
+
+ @ObservationIgnored private let client: FleetClient
+ @ObservationIgnored private var isFinishing = false
+ /// Reading the cookie store suspends, and both navigation callbacks check, so checks are serialised.
+ @ObservationIgnored private var isCheckingForSession = false
+
+ init(client: FleetClient = .shared) {
+ self.client = client
+ super.init()
+ }
+
+ // MARK: - Presenting
+
+ /// Opens the sheet and asks Fleet for the identity provider's URL.
+ func present() {
+ guard !isPresented else { return }
+ isPresented = true
+ Task { await start() }
+ }
+
+ func cancel() {
+ isPresented = false
+ teardown()
+ isFinishing = false
+ phase = .idle
+ }
+
+ /// Clears the session so the next gated request asks for sign-in again.
+ func signOut() async {
+ await client.signOutSSO()
+ await Self.clearFleetCookies()
+ phase = .idle
+ }
+
+ // MARK: - The flow
+
+ func start() async {
+ teardown()
+ // A previous attempt may have ended in an error, which leaves this set
+ isFinishing = false
+ phase = .starting
+
+ let initiation: FleetSSOInitiation
+ do {
+ initiation = try await client.initiateSSO()
+ } catch {
+ Logger.shared.logError("Fleet: couldn't start Fleet Desktop SSO: \(error.localizedDescription)")
+ phase = .failed(error.localizedDescription)
+ return
+ }
+
+ // The sheet may have been cancelled while Fleet was answering
+ guard isPresented else { return }
+
+ // A sign-in that was interrupted can leave Fleet cookies behind, and a stale session cookie
+ // would be taken for this one's result before the user has typed anything, so the slate is
+ // wiped first: anything found from here on was set by this sign-in.
+ await Self.clearFleetCookies()
+
+ guard isPresented else { return }
+
+ // The web view has its own cookie store, so Fleet's handshake cookie has to be planted there
+ // or its callback has nothing to match the assertion against and the sign-in loops.
+ let dataStore = WKWebsiteDataStore.default()
+ if let handshake = initiation.handshakeCookie {
+ await dataStore.httpCookieStore.setCookie(handshake)
+ } else {
+ Logger.shared.logError("Fleet: Fleet Desktop SSO started without a handshake cookie")
+ }
+
+ let configuration = WKWebViewConfiguration()
+ configuration.websiteDataStore = dataStore
+ let webView = WKWebView(frame: .zero, configuration: configuration)
+ webView.navigationDelegate = self
+ self.webView = webView
+ phase = .signingIn
+ webView.load(URLRequest(url: initiation.idpURL))
+ }
+
+ /// Looks for the session Fleet's callback sets, and for the error it redirects with instead.
+ private func checkForSession() async {
+ guard !isFinishing, !isCheckingForSession, let webView else { return }
+ isCheckingForSession = true
+ defer { isCheckingForSession = false }
+
+ if let url = webView.url, let reason = Self.ssoErrorReason(in: url) {
+ Logger.shared.logError("Fleet: Fleet Desktop SSO failed: \(reason)")
+ isFinishing = true
+ phase = .failed(Self.message(forErrorReason: reason))
+ teardown()
+ return
+ }
+
+ let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies()
+ guard let session = cookies.first(where: { $0.name == FleetClient.ssoSessionCookieName }),
+ !session.value.isEmpty else {
+ return
+ }
+
+ isFinishing = true
+ phase = .completing
+ await client.completeSSO(cookie: session.value, expiresAt: session.expiresDate)
+ // The session is in the keychain now; the web view's copy would be a second, weaker one
+ await Self.clearFleetCookies()
+ teardown()
+ phase = .idle
+ isPresented = false
+ isFinishing = false
+ Logger.shared.logDebug("Fleet: signed in with Fleet Desktop SSO")
+ onSignedIn?()
+ }
+
+ /// Fleet redirects to the device page with `sso_error=` when the callback couldn't mint a
+ /// session, e.g. because an admin turned the feature off mid-flow.
+ nonisolated static func ssoErrorReason(in url: URL) -> String? {
+ URLComponents(url: url, resolvingAgainstBaseURL: false)?
+ .queryItems?
+ .first { $0.name == "sso_error" }?
+ .value
+ .flatMap { $0.isEmpty ? nil : $0 }
+ }
+
+ private static func message(forErrorReason reason: String) -> String {
+ switch reason {
+ case "sso_disabled": return Constants.Fleet.ssoDisabled
+ default: return Constants.Fleet.ssoFailed
+ }
+ }
+
+ /// Removes the Fleet cookies from the web view's store, leaving the identity provider's own so a
+ /// later sign-in can still be silent.
+ private static func clearFleetCookies() async {
+ let store = WKWebsiteDataStore.default().httpCookieStore
+ for cookie in await store.allCookies()
+ where cookie.name == FleetClient.ssoSessionCookieName || cookie.name == FleetClient.ssoHandshakeCookieName {
+ await store.deleteCookie(cookie)
+ }
+ }
+
+ private func teardown() {
+ webView?.stopLoading()
+ webView?.navigationDelegate = nil
+ webView = nil
+ }
+}
+
+extension FleetSSOController: WKNavigationDelegate {
+ /// Checked as each response arrives rather than only when a page finishes: Fleet's callback sets
+ /// the session on a redirect, and the device page it redirects to can take a while to render.
+ func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
+ Task { await checkForSession() }
+ }
+
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
+ Task { await checkForSession() }
+ }
+
+ func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
+ fail(with: error)
+ }
+
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
+ fail(with: error)
+ }
+
+ private func fail(with error: Error) {
+ // A cancelled load is the redirect chain moving on, not a failure
+ guard (error as NSError).code != NSURLErrorCancelled, !isFinishing else { return }
+ Logger.shared.logError("Fleet: the Fleet Desktop SSO page failed to load: \(error.localizedDescription)")
+ phase = .failed(error.localizedDescription)
+ }
+}
diff --git a/SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift b/SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift
new file mode 100644
index 0000000..517290d
--- /dev/null
+++ b/SupportCompanion/Services/Fleet/FleetSSOSessionStore.swift
@@ -0,0 +1,103 @@
+//
+// FleetSSOSessionStore.swift
+// SupportCompanion
+//
+// Keychain storage for the Fleet Desktop SSO session.
+//
+// The cookie Fleet issues after sign-in is a bearer credential for this Mac's device API and lasts
+// for the server's `session.duration` (5 days by default), so it's kept in the keychain rather than
+// in defaults, and it's scoped to the host that issued it: a Mac repointed at another Fleet server
+// signs in again instead of sending the old server's session to the new one.
+//
+
+import Foundation
+import Security
+
+enum FleetSSOSessionStore {
+ struct Session: Codable, Sendable, Equatable {
+ let cookie: String
+ /// Host of the Fleet server that issued it.
+ let host: String
+ /// When Fleet's cookie expires, or nil if it came without a Max-Age.
+ let expiresAt: Date?
+
+ /// Whether the session can still be used against `host`, with a minute of slack so a session
+ /// about to expire isn't sent only to come back as an SSO prompt.
+ func isUsable(on host: String, now: Date = Date()) -> Bool {
+ guard self.host.caseInsensitiveCompare(host) == .orderedSame else { return false }
+ guard let expiresAt else { return true }
+ return expiresAt > now.addingTimeInterval(60)
+ }
+ }
+
+ private static let service = "com.github.macadmins.SupportCompanion.fleet-sso"
+ private static let account = "device-session"
+
+ static func save(_ session: Session) {
+ guard let data = try? JSONEncoder().encode(session) else { return }
+ // NOTE: this lands in the file-based login keychain, where `kSecAttrAccessible` is ignored --
+ // it only takes effect in the data protection keychain, which needs
+ // `kSecUseDataProtectionKeychain` and a keychain-access-group entitlement. So the intent below
+ // is NOT enforced: the session is readable whenever the login keychain is unlocked, and it can
+ // travel to another Mac in a backup or a migration. What actually guards it is the login
+ // keychain's ACL, which limits reads to this app's code signature.
+ //
+ // Accepted deliberately: the credential is a bearer token scoped to this Mac's Fleet device
+ // API and expires in 5 days. Revisit if that scope grows.
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
+ ]
+ let status = SecItemUpdate(query() as CFDictionary, attributes as CFDictionary)
+ if status == errSecItemNotFound {
+ var item = query()
+ item.merge(attributes) { _, new in new }
+ let addStatus = SecItemAdd(item as CFDictionary, nil)
+ if addStatus != errSecSuccess {
+ Logger.shared.logError("Fleet: couldn't store the SSO session (\(addStatus))")
+ }
+ } else if status != errSecSuccess {
+ Logger.shared.logError("Fleet: couldn't update the SSO session (\(status))")
+ }
+ }
+
+ /// The stored session if it's still usable on `host`, clearing it when it isn't.
+ static func load(host: String) -> Session? {
+ var item = query()
+ item[kSecReturnData as String] = true
+ item[kSecMatchLimit as String] = kSecMatchLimitOne
+
+ var result: CFTypeRef?
+ let status = SecItemCopyMatching(item as CFDictionary, &result)
+ guard status == errSecSuccess, let data = result as? Data else {
+ if status != errSecItemNotFound {
+ Logger.shared.logError("Fleet: couldn't read the SSO session (\(status))")
+ }
+ return nil
+ }
+ guard let session = try? JSONDecoder().decode(Session.self, from: data) else {
+ clear()
+ return nil
+ }
+ guard session.isUsable(on: host) else {
+ clear()
+ return nil
+ }
+ return session
+ }
+
+ static func clear() {
+ let status = SecItemDelete(query() as CFDictionary)
+ if status != errSecSuccess && status != errSecItemNotFound {
+ Logger.shared.logError("Fleet: couldn't clear the SSO session (\(status))")
+ }
+ }
+
+ private static func query() -> [String: Any] {
+ [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ ]
+ }
+}
diff --git a/SupportCompanion/HelperRemoteProvider.swift b/SupportCompanion/Services/HelperRemoteProvider.swift
similarity index 61%
rename from SupportCompanion/HelperRemoteProvider.swift
rename to SupportCompanion/Services/HelperRemoteProvider.swift
index 2f69cd8..bb1a076 100644
--- a/SupportCompanion/HelperRemoteProvider.swift
+++ b/SupportCompanion/Services/HelperRemoteProvider.swift
@@ -7,6 +7,7 @@
import Foundation
import ServiceManagement
+import Security
// MARK: - HelperRemoteProvider
@@ -15,7 +16,18 @@ enum HelperRemoteProvider {
// MARK: Computed
- private static var isHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) }
+ /// Whether the copy of the helper that *this package* installs is present.
+ ///
+ /// Not the same question as "is a helper available". When the helper is deployed declaratively
+ /// with `com.apple.configuration.services.background-tasks`, it lives in a managed directory named
+ /// after the administrator's chosen `TaskType`, which we cannot know and must not guess at. So this
+ /// only ever gates self-installation: if our own copy is not here, something else is responsible
+ /// for the helper and registering a second one would fight it.
+ private static var isPackagedHelperInstalled: Bool { FileManager.default.fileExists(atPath: HelperConstants.helperPath) }
+
+ // MARK: Exported app proxy for XPC (optional but safer than exporting the enum type)
+ private final class RemoteAppProxy: NSObject, RemoteApplicationProtocol {}
+ private static let exportedAppProxy = RemoteAppProxy()
}
// MARK: - Remote
@@ -57,46 +69,19 @@ extension HelperRemoteProvider {
extension HelperRemoteProvider {
- /// Install the Helper in the privileged helper tools folder and load the daemon
- private static func installHelper() throws {
-
- // try to get a valid empty authorization
- var authRef: AuthorizationRef?
- try AuthorizationCreate(nil, nil, [.preAuthorize], &authRef).checkError("AuthorizationCreate")
- defer {
- if let authRef {
- AuthorizationFree(authRef, [])
- }
- }
-
- // create an AuthorizationItem to specify we want to bless a privileged Helper
- let authStatus = kSMRightBlessPrivilegedHelper.withCString { authorizationString in
- var authItem = AuthorizationItem(name: authorizationString, valueLength: 0, value: nil, flags: 0)
-
- return withUnsafeMutablePointer(to: &authItem) { pointer in
- var authRights = AuthorizationRights(count: 1, items: pointer)
- let flags: AuthorizationFlags = [.interactionAllowed, .extendRights, .preAuthorize]
- return AuthorizationCreate(&authRights, nil, flags, &authRef)
- }
- }
-
- guard authStatus == errAuthorizationSuccess else {
- throw SupportCompanionErrors.helperInstallation("Unable to get a valid loading authorization reference to load Helper daemon")
+ /// Install the Helper in the privileged helper tools folder and register the daemon using SMAppService
+ private static func installHelperModern() throws {
+ do {
+ let service = SMAppService.daemon(plistName: HelperConstants.domain)
+ try service.register()
+ } catch {
+ Logger.shared.logError("SMAppService register failed: \(error.localizedDescription)")
+ throw SupportCompanionErrors.helperInstallation("Error while installing the Helper: \(error.localizedDescription)")
}
+ }
- // After calling SMJobBless
- var blessErrorPointer: Unmanaged?
- let wasBlessed = SMJobBless(kSMDomainSystemLaunchd, HelperConstants.domain as CFString, authRef, &blessErrorPointer)
-
- guard wasBlessed else {
- if let blessErrorPointer {
- let errorDescription = CFErrorCopyDescription(blessErrorPointer.takeRetainedValue())
- Logger.shared.logError("SMJobBless failed: \(errorDescription as String? ?? "Unknown error")")
- throw SupportCompanionErrors.helperInstallation("Error while installing the Helper: \(errorDescription as String? ?? "Unknown error")")
- } else {
- throw SupportCompanionErrors.helperInstallation("Unknown error while installing the Helper")
- }
- }
+ private static func installHelper() throws {
+ try installHelperModern()
}
}
@@ -105,9 +90,15 @@ extension HelperRemoteProvider {
extension HelperRemoteProvider {
static private func connection() throws -> NSXPCConnection {
- if !isHelperInstalled {
+ // When the helper is deployed declaratively it answers on the same Mach service from a managed
+ // directory, and our file is legitimately absent. Registering the bundled copy then would put a
+ // second daemon on the same Mach service, so the administrator says which deployment is in use.
+ let deployedElsewhere = TrustedPreferences.bool(forKey: "SkipHelperInstall", default: false)
+
+ if !isPackagedHelperInstalled && !deployedElsewhere {
try installHelper()
}
+
return createConnection()
}
@@ -115,13 +106,13 @@ extension HelperRemoteProvider {
let connection = NSXPCConnection(machServiceName: HelperConstants.domain, options: .privileged)
connection.remoteObjectInterface = NSXPCInterface(with: HelperProtocol.self)
connection.exportedInterface = NSXPCInterface(with: RemoteApplicationProtocol.self)
- connection.exportedObject = self
+ connection.exportedObject = exportedAppProxy
connection.invalidationHandler = {
- if isHelperInstalled {
+ if isPackagedHelperInstalled {
Logger.shared.logError("Unable to connect to Helper although it is installed")
} else {
- Logger.shared.logError("Help is not installed")
+ Logger.shared.logError("Unable to connect to Helper. It is not installed by the package; if it is deployed declaratively, check that the declaration has been applied")
}
}
diff --git a/SupportCompanion/Services/NotificationService.swift b/SupportCompanion/Services/NotificationService.swift
index 61fe7c2..a997804 100644
--- a/SupportCompanion/Services/NotificationService.swift
+++ b/SupportCompanion/Services/NotificationService.swift
@@ -9,8 +9,15 @@ import Foundation
import UserNotifications
import SwiftUI
+@MainActor
class NotificationService {
private let appState: AppStateManager
+ /// Every category registered by this app. Registering replaces the whole set, so it's kept here and
+ /// always registered in full, rather than read back and merged by notifications sent at the same time.
+ private static var categories: [String: UNNotificationCategory] = [:]
+ /// Categories registered by earlier launches, which notifications still in Notification Center use.
+ /// Read once, before this launch registers anything.
+ private static var loadedEarlierCategories = false
init(appState: AppStateManager) {
self.appState = appState
@@ -34,33 +41,46 @@ class NotificationService {
}
}
+ /// Command that updates every Fleet title with an update available, for update notifications.
+ static let fleetUpdateAllCommand = "fleet-update-all"
+ /// Followed by a title id: quits that app and retries its install.
+ static let fleetQuitAndRetryCommand = "fleet-quit-and-retry:"
+
+ /// - Parameters:
+ /// - command: Run by the button. `demote`, `fleet-update-all` and `open supportcompanion://…` are handled in the app.
+ /// - openURL: A `supportcompanion://` page opened when the notification itself is clicked.
func sendNotification(
message: String,
buttonText: String? = nil,
command: String? = nil,
+ openURL: String? = nil,
notificationType: NotificationType
) {
- guard appState.preferences.notificationInterval > 0 else {
+ guard appState.preferences.notifications.notificationInterval > 0 else {
Logger.shared.logDebug("Notification interval set to 0, skipping notification")
return
}
- let imagePath = appState.preferences.notificationImage.isEmpty ? nil : appState.preferences.notificationImage
+ let imagePath = appState.preferences.notifications.notificationImage.isEmpty ? nil : appState.preferences.notifications.notificationImage
if notificationType != .generic {
if let lastDate = AppStorageHelper.shared.getLastNotificationDate(for: notificationType),
- Date().timeIntervalSince(lastDate) < TimeInterval(appState.preferences.notificationInterval * 3600) {
+ Date().timeIntervalSince(lastDate) < TimeInterval(appState.preferences.notifications.notificationInterval * 3600) {
Logger.shared.logDebug("Notification interval for \(notificationType) not reached, skipping notification")
return
}
}
+ let notificationCommand = command?.isEmpty == false ? command : nil
let content = UNMutableNotificationContent()
- content.title = appState.preferences.notificationTitle
+ content.title = appState.preferences.notifications.notificationTitle
content.body = message
content.sound = .default
- content.userInfo = ["Command": command]
- content.categoryIdentifier = "ACTIONABLE"
+ var userInfo: [String: Any] = ["Command": notificationCommand as Any]
+ if let openURL, openURL.hasPrefix("supportcompanion://") {
+ userInfo["OpenURL"] = openURL
+ }
+ content.userInfo = userInfo
if let imagePath = imagePath, let tempURL = prepareImageForNotification(imagePath: imagePath) {
do {
@@ -72,7 +92,7 @@ class NotificationService {
}
var actions: [UNNotificationAction] = []
- if let buttonText = buttonText, let command = command {
+ if let buttonText = buttonText, notificationCommand != nil {
let action = UNNotificationAction(
identifier: "RUN_COMMAND",
title: buttonText,
@@ -81,22 +101,43 @@ class NotificationService {
actions.append(action)
}
+ // A category per button title: categories are shared, so reusing one would change the buttons of
+ // notifications already delivered
let category = UNNotificationCategory(
- identifier: "ACTIONABLE",
+ identifier: actions.isEmpty ? "PLAIN" : "ACTIONABLE.\(buttonText ?? "")",
actions: actions,
intentIdentifiers: []
)
- UNUserNotificationCenter.current().setNotificationCategories([category])
+ content.categoryIdentifier = category.identifier
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
- UNUserNotificationCenter.current().add(request) { error in
- if let error = error {
- Logger.shared.logDebug("Failed to deliver notification: \(error.localizedDescription)")
- } else {
+ Task {
+ let center = UNUserNotificationCenter.current()
+ await Self.register(category, in: center)
+ do {
+ try await center.add(request)
Logger.shared.logDebug("Notification sent: \(message)")
AppStorageHelper.shared.setLastNotificationDate(Date(), for: notificationType)
+ } catch {
+ Logger.shared.logDebug("Failed to deliver notification: \(error.localizedDescription)")
+ }
+ }
+ }
+
+ private static func register(_ category: UNNotificationCategory, in center: UNUserNotificationCenter) async {
+ if !loadedEarlierCategories {
+ let earlier = await center.notificationCategories()
+ if !loadedEarlierCategories {
+ loadedEarlierCategories = true
+ for existing in earlier where categories[existing.identifier] == nil {
+ categories[existing.identifier] = existing
+ }
}
}
+ // Checked and registered without suspending, so notifications sent together can't undo each other
+ guard categories[category.identifier] == nil else { return }
+ categories[category.identifier] = category
+ center.setNotificationCategories(Set(categories.values))
}
}
@@ -106,18 +147,37 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
- if response.actionIdentifier == "RUN_COMMAND",
- let command = response.notification.request.content.userInfo["Command"] as? String {
+ if response.actionIdentifier == UNNotificationDefaultActionIdentifier,
+ let openURL = response.notification.request.content.userInfo["OpenURL"] as? String {
+ Logger.shared.logDebug("Notification clicked, opening \(openURL)")
+ ActionHelpers.openManagementApp(appURL: openURL)
+ } else if response.actionIdentifier == "RUN_COMMAND",
+ let command = response.notification.request.content.userInfo["Command"] as? String {
Logger.shared.logDebug("Notification button clicked, running command: \(command)")
if command == "demote" {
- AppStateManager.shared.stopDemotionTimer()
- ElevationManager.shared.demotePrivileges { success in
- if success {
- Logger.shared.logDebug("Successfully demoted privileges")
- } else {
- Logger.shared.logError("Failed to demote privileges")
+ Task { @MainActor in
+ AppStateManager.shared.stopDemotionTimer()
+ ElevationManager.shared.demotePrivileges { success in
+ if success {
+ Logger.shared.logDebug("Successfully demoted privileges")
+ } else {
+ Logger.shared.logError("Failed to demote privileges")
+ }
}
}
+ } else if command == NotificationService.fleetUpdateAllCommand {
+ Task { @MainActor in
+ ActionHelpers.openManagementApp(appURL: "supportcompanion://apps")
+ await AppStateManager.shared.fleetSoftwareManager.updateAll()
+ }
+ } else if command.hasPrefix(NotificationService.fleetQuitAndRetryCommand),
+ let titleID = Int(command.dropFirst(NotificationService.fleetQuitAndRetryCommand.count)) {
+ Task { @MainActor in
+ ActionHelpers.openManagementApp(appURL: "supportcompanion://apps")
+ await AppStateManager.shared.fleetSoftwareManager.quitAndRetry(titleID: titleID)
+ }
+ } else if command.hasPrefix("open supportcompanion://") {
+ ActionHelpers.openManagementApp(appURL: String(command.dropFirst("open ".count)))
} else {
Task {
do {
@@ -133,44 +193,33 @@ class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
}
}
+@MainActor
class BadgeManager {
static let shared = BadgeManager()
private(set) var badgeCount = 0
- private let lock = NSLock()
func incrementBadgeCount(count: Int) {
- lock.lock()
badgeCount = count
- lock.unlock()
updateBadge()
}
func currentBadgeCount() -> Int {
- lock.lock()
- let count = badgeCount
- lock.unlock()
- return count
+ return badgeCount
}
private func updateBadge() {
- DispatchQueue.main.async {
- if self.badgeCount > 0 {
- let prefs = AppStateManager.shared.preferences
- let hasPendingUpdates = !prefs.hiddenCards.contains("PendingAppUpdates") && AppStateManager.shared.pendingUpdatesCount > 0
- let hasSoftwareUpdates = !prefs.hiddenActions.contains("SoftwareUpdates") && AppStateManager.shared.systemUpdateCache.count > 0
- if hasPendingUpdates || hasSoftwareUpdates {
- NSApplication.shared.dockTile.showsApplicationBadge = true
- NSApplication.shared.dockTile.badgeLabel = nil
- NSApplication.shared.dockTile.badgeLabel = String(self.badgeCount)
- } else {
- NSApplication.shared.dockTile.showsApplicationBadge = false
- NSApplication.shared.dockTile.badgeLabel = nil
- }
-
- } else {
+ if badgeCount > 0 {
+ if AppStateManager.shared.attentionCount > 0 {
+ NSApplication.shared.dockTile.showsApplicationBadge = true
NSApplication.shared.dockTile.badgeLabel = nil
+ NSApplication.shared.dockTile.badgeLabel = String(badgeCount)
+ } else {
NSApplication.shared.dockTile.showsApplicationBadge = false
+ NSApplication.shared.dockTile.badgeLabel = nil
}
+ } else {
+ NSApplication.shared.dockTile.badgeLabel = nil
+ NSApplication.shared.dockTile.showsApplicationBadge = false
}
}
}
@@ -183,6 +232,7 @@ enum NotificationType: String {
}
+@MainActor
class AppStorageHelper {
// Singleton instance
static let shared = AppStorageHelper(appState: AppStateManager.shared)
@@ -197,13 +247,13 @@ class AppStorageHelper {
let formattedDate = ISO8601DateFormatter().string(from: date)
switch type {
case .softwareUpdate:
- appState.preferences.lastSoftwareUpdateNotificationTime = formattedDate
+ appState.preferences.notifications.lastSoftwareUpdateNotificationTime = formattedDate
case .rebootReminder:
- appState.preferences.lastRebootReminderNotificationTime = formattedDate
+ appState.preferences.notifications.lastRebootReminderNotificationTime = formattedDate
case .generic:
- appState.preferences.lastGenericNotificationTime = formattedDate
+ appState.preferences.notifications.lastGenericNotificationTime = formattedDate
case .appUpdate:
- appState.preferences.lastAppUpdateNotificationTime = formattedDate
+ appState.preferences.notifications.lastAppUpdateNotificationTime = formattedDate
}
}
@@ -211,13 +261,13 @@ class AppStorageHelper {
let dateString: String
switch type {
case .softwareUpdate:
- dateString = appState.preferences.lastSoftwareUpdateNotificationTime
+ dateString = appState.preferences.notifications.lastSoftwareUpdateNotificationTime
case .rebootReminder:
- dateString = appState.preferences.lastRebootReminderNotificationTime
+ dateString = appState.preferences.notifications.lastRebootReminderNotificationTime
case .generic:
- dateString = appState.preferences.lastGenericNotificationTime
+ dateString = appState.preferences.notifications.lastGenericNotificationTime
case .appUpdate:
- dateString = appState.preferences.lastAppUpdateNotificationTime
+ dateString = appState.preferences.notifications.lastAppUpdateNotificationTime
}
guard !dateString.isEmpty else { return nil }
return ISO8601DateFormatter().date(from: dateString)
diff --git a/SupportCompanion/Services/SystemUpdateService.swift b/SupportCompanion/Services/SystemUpdateService.swift
deleted file mode 100644
index d8614b2..0000000
--- a/SupportCompanion/Services/SystemUpdateService.swift
+++ /dev/null
@@ -1,22 +0,0 @@
-//
-// SystemUpdateService.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-19.
-//
-
-import Foundation
-
-struct SystemUpdateService {
- static func fetchSystemUpdates() async -> Result<(Int, [String]), Error> {
- do {
- let result = try await ExecutionService.executeCommand("/usr/sbin/softwareupdate", with: ["-l"])
- let lines = result.split(whereSeparator: \.isNewline)
-
- let updates = lines.filter { $0.contains("*") }.map { String($0) }
- return .success((updates.count, updates))
- } catch {
- return .failure(error)
- }
- }
-}
diff --git a/SupportCompanion/SupportCompanion-Debug.entitlements b/SupportCompanion/SupportCompanion-Debug.entitlements
new file mode 100644
index 0000000..fbadecf
--- /dev/null
+++ b/SupportCompanion/SupportCompanion-Debug.entitlements
@@ -0,0 +1,30 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.files.user-selected.read-only
+
+ com.apple.security.application-groups
+
+ com.apple.security.inherit
+
+
+ com.apple.security.cs.disable-library-validation
+
+
+
diff --git a/SupportCompanion/SupportCompanion.swift b/SupportCompanion/SupportCompanion.swift
deleted file mode 100644
index ff30eda..0000000
--- a/SupportCompanion/SupportCompanion.swift
+++ /dev/null
@@ -1,38 +0,0 @@
-import SwiftUI
-import Foundation
-import ServiceManagement
-
-@main
-struct SupportCompanion: App {
-
- @StateObject private var appStateManager = AppStateManager.shared
- @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
-
- /*var body: some Scene {
- WindowGroup {
- ContentView()
- .navigationTitle("")
- .ignoresSafeArea(.all)
- .frame(width: 1400, height: 900)
- .environmentObject(appStateManager)
- .onAppear(perform: {
- Task {
- appStateManager.refreshAll() // Ensure asynchronous call is done on launch
- appStateManager.systemUpdatesManager.startMonitoring()
- }
- })
- .onDisappear(perform: {
- appStateManager.systemUpdatesManager.stopMonitoring()
- })
- .hidden()
- }
- .windowResizability(.contentSize)
- .windowStyle(.hiddenTitleBar)
- }*/
-
- var body: some Scene {
- Settings {
- EmptyView() // Use this to suppress unwanted UI elements like Preferences
- }
- }
-}
diff --git a/SupportCompanion/Utilities/ImageUtilities.swift b/SupportCompanion/Utilities/ImageUtilities.swift
index f6c930a..3594a1c 100644
--- a/SupportCompanion/Utilities/ImageUtilities.swift
+++ b/SupportCompanion/Utilities/ImageUtilities.swift
@@ -24,7 +24,7 @@ func base64ToImage(_ base64String: String) -> Image? {
func loadLogo(base64Logo: String) -> Bool {
if base64Logo.isEmpty {
return false
- } else if let decodedImage = base64ToImage(base64Logo) {
+ } else if let _ = base64ToImage(base64Logo) {
return true
} else {
Logger.shared.logDebug("Invalid Base64 string for brand logo.")
diff --git a/SupportCompanion/ViewModels/AppStateManager.swift b/SupportCompanion/ViewModels/AppStateManager.swift
index 6829398..c0cbcee 100644
--- a/SupportCompanion/ViewModels/AppStateManager.swift
+++ b/SupportCompanion/ViewModels/AppStateManager.swift
@@ -6,49 +6,122 @@
//
import Foundation
-import Combine
+import Observation
import SwiftUI
-class AppStateManager: ObservableObject {
+@MainActor
+@Observable
+class AppStateManager {
static let shared = AppStateManager()
- lazy var systemUpdatesManager = SystemUpdatesManager(appState: self)
- lazy var pendingMunkiUpdatesManager = PendingMunkiUpdatesManager(appState: self)
- lazy var applicationsInfoManager = ApplicationsInfoManager(appState: self)
- lazy var pendingIntuneUpdatesManager = PendingIntuneUpdatesManager(appState: self)
- lazy var evergreenInfoManager = EvergreenInfoManager(appState: self)
- lazy var elevationManager = ElevationManager(appState: self)
- var jsonCardManager: JsonCardManager?
- @Published var isRefreshing: Bool = false
- @Published var deviceInfoManager = DeviceInfoManager.shared
- @Published var storageInfoManager = StorageInfoManager.shared
- @Published var mdmInfoManager = MdmInfoManager.shared
- @Published var batteryInfoManager = BatteryInfoManager.shared
- @Published var ssoInfoManager = SSOInfoManager.shared
- @Published var userInfoManager = UserInfoManager.shared
- @Published var preferences = Preferences()
- @Published var installPercentage: Double = 0.0
- @Published var installedAppsCount: Int = 0
- @Published var pendingUpdatesCount: Int = 0
- @Published var pendingMunkiUpdates: [PendingMunkiUpdate] = []
- @Published var pendingIntuneUpdates: [PendingIntuneUpdate] = []
- @Published var installedApplications: [InstalledApp] = []
- @Published var systemUpdateCache: SystemUpdates = SystemUpdates(id: UUID(), count: 0, updates: [])
- @Published var windowIsVisible: Bool = false
- @Published var storageUsageColor: Color = Color(NSColor.controlAccentColor)
- @Published var JsonCards: [JsonCard] = []
- @Published var catalogs: [String] = []
- @Published var isDemotionActive: Bool = false
- @Published var timeToDemote: TimeInterval = 0
-
- private var cancellables: Set = Set()
- var showWindowCallback: (() -> Void)?
+ @ObservationIgnored lazy var systemUpdatesManager = SystemUpdatesManager(appState: self)
+ @ObservationIgnored lazy var pendingMunkiUpdatesManager = PendingMunkiUpdatesManager(appState: self)
+ @ObservationIgnored lazy var applicationsInfoManager = ApplicationsInfoManager(appState: self)
+ @ObservationIgnored lazy var pendingIntuneUpdatesManager = PendingIntuneUpdatesManager(appState: self)
+ @ObservationIgnored lazy var pendingJamfUpdatesManager = PendingJamfUpdatesManager(appState: self)
+ @ObservationIgnored lazy var pendingFleetUpdatesManager = PendingFleetUpdatesManager(appState: self)
+ @ObservationIgnored lazy var evergreenInfoManager = EvergreenInfoManager(appState: self)
+ // The shared one, never a fresh instance. The countdown lives on the manager, while the value it
+ // publishes lives here — so two managers mean two timers writing one `timeToDemote`, and stopping
+ // one leaves the other to write its own value straight back.
+ @ObservationIgnored lazy var elevationManager = ElevationManager.shared
+ @ObservationIgnored lazy var fleetSoftwareManager: FleetSoftwareManager = {
+ let manager = FleetSoftwareManager()
+ manager.onActionFinished = { [weak self] title, action, outcome in
+ self?.notifyFleetActionFinished(title, action: action, outcome: outcome)
+ }
+ manager.onCatalogUpdated = { [weak self] in
+ self?.pendingFleetUpdatesManager.publishCounts()
+ }
+ return manager
+ }()
+ /// Drives Fleet Desktop SSO sign-in, and reloads what the SSO gate blocked once it succeeds.
+ @ObservationIgnored lazy var fleetSSOController: FleetSSOController = {
+ let controller = FleetSSOController()
+ controller.onSignedIn = { [weak self] in
+ Task {
+ await self?.fleetSoftwareManager.refresh()
+ await self?.fleetDeviceManager.refresh()
+ }
+ }
+ return controller
+ }()
+ @ObservationIgnored lazy var fleetDeviceManager: FleetDeviceManager = {
+ let manager = FleetDeviceManager()
+ manager.onNewlyFailing = { [weak self] policies in
+ self?.notifyFleetPoliciesFailing(policies)
+ }
+ manager.onRefetchFinished = { [weak self] in
+ Task { await self?.fleetSoftwareManager.refresh() }
+ }
+ manager.onSignInRequired = { [weak self] in
+ self?.notifyFleetSignInRequired()
+ }
+ return manager
+ }()
+ @ObservationIgnored var jsonCardManager: JsonCardManager?
+ var isRefreshing: Bool = false
+ var jamfId: String = ""
+ let deviceInfoManager = DeviceInfoManager.shared
+ let storageInfoManager = StorageInfoManager.shared
+ let mdmInfoManager = MdmInfoManager.shared
+ let batteryInfoManager = BatteryInfoManager.shared
+ let ssoInfoManager = SSOInfoManager.shared
+ let userInfoManager = UserInfoManager.shared
+ let preferences = Preferences()
+ var installPercentage: Double = 0.0
+ var installedAppsCount: Int = 0
+ var pendingUpdatesCount: Int = 0
+ var pendingMunkiUpdates: [PendingMunkiUpdate] = []
+ var pendingIntuneUpdates: [PendingIntuneUpdate] = []
+ var pendingJamfUpdates: [PendingJamfUpdate] = []
+ var installedApplications: [InstalledApp] = []
+ var systemUpdateCache: SystemUpdates = SystemUpdates(id: UUID(), count: 0, updates: [], hasBackgroundSecurityImprovement: false)
+ var windowIsVisible: Bool = false
+ var storageUsageColor: Color = Color(NSColor.controlAccentColor)
+ var JsonCards: [JsonCard] = []
+ var catalogs: [String] = []
+ var isDemotionActive: Bool = false
+ var timeToDemote: TimeInterval = 0
+ @ObservationIgnored var jamfInfoManager: JamfInfoManager!
+
+ @ObservationIgnored private var customCardPathObservation: ObservationToken?
+ @ObservationIgnored var showWindowCallback: (() -> Void)?
+
+ /// The pending-updates manager for the configured mode, or nil when the mode has none (System Profiler).
+ /// Views and background tasks should go through this rather than checking the mode themselves.
+ var activeUpdatesManager: PendingUpdatesManager? {
+ switch preferences.mode {
+ case Constants.Modes.munki: return pendingMunkiUpdatesManager
+ case Constants.Modes.intune: return pendingIntuneUpdatesManager
+ case Constants.Modes.jamf: return pendingJamfUpdatesManager
+ case Constants.Modes.fleet: return pendingFleetUpdatesManager
+ default: return nil
+ }
+ }
+
+ /// Failing Fleet compliance checks, when the compliance card is shown.
+ var fleetFailingChecksCount: Int {
+ guard preferences.mode == Constants.Modes.fleet,
+ !preferences.hiddenCards.contains(Constants.Cards.fleetPolicies) else { return 0 }
+ // The ungated count when signed out, so the badge doesn't silently drop to zero
+ return fleetDeviceManager.failingChecksCount ?? 0
+ }
+
+ /// What needs the user's attention, for the menu bar dot and Dock badge: pending app updates, macOS
+ /// updates and failing compliance checks, each counted only when its card or button is shown.
+ var attentionCount: Int {
+ let appUpdates = preferences.hiddenCards.contains(Constants.Cards.pendingAppUpdates) ? 0 : pendingUpdatesCount
+ let systemUpdates = preferences.hiddenActions.contains(Constants.Actions.HideStrings.softwareUpdate) ? 0 : systemUpdateCache.count
+ return appUpdates + systemUpdates + fleetFailingChecksCount
+ }
func startBackgroundTasks() {
- if preferences.mode == Constants.modes.munki {
- pendingMunkiUpdatesManager.startUpdateCheckTimer()
+ activeUpdatesManager?.startUpdateCheckTimer()
+ if preferences.mode == Constants.Modes.jamf && !preferences.hiddenCards.contains(Constants.Cards.jamfInfo) {
+ jamfInfoManager.startMonitoring()
}
- if preferences.mode == Constants.modes.intune {
- pendingIntuneUpdatesManager.startUpdateCheckTimer()
+ if preferences.mode == Constants.Modes.fleet {
+ fleetDeviceManager.startMonitoring()
}
systemUpdatesManager.startMonitoring()
storageInfoManager.startMonitoring()
@@ -56,52 +129,59 @@ class AppStateManager: ObservableObject {
}
func stopBackgroundTasks() {
- pendingMunkiUpdatesManager.stopUpdateCheckTimer()
- pendingIntuneUpdatesManager.stopUpdateCheckTimer()
+ // Stop every manager, not just the active one, in case the mode changed while running
+ for manager in [pendingMunkiUpdatesManager, pendingIntuneUpdatesManager, pendingJamfUpdatesManager, pendingFleetUpdatesManager] as [PendingUpdatesManager] {
+ manager.stopUpdateCheckTimer()
+ }
+ fleetDeviceManager.stopMonitoring()
systemUpdatesManager.stopMonitoring()
storageInfoManager.stopMonitoring()
deviceInfoManager.stopMonitoring()
+ if !preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && preferences.mode == Constants.Modes.jamf {
+ jamfInfoManager.stopMonitoring()
+ }
}
-
+
init() {
- // Forward changes from `SystemUpdatesManager`
- systemUpdatesManager.objectWillChange
- .sink { [weak self] _ in
- self?.objectWillChange.send()
- }
- .store(in: &cancellables)
-
- storageInfoManager.objectWillChange
- .sink { [weak self] _ in
- self?.objectWillChange.send()
- }
- .store(in: &cancellables)
-
- deviceInfoManager.objectWillChange
- .sink { [weak self] _ in
- self?.objectWillChange.send()
- }
- .store(in: &cancellables)
-
- ssoInfoManager.objectWillChange
- .sink { [weak self] _ in
- self?.objectWillChange.send()
- }
- .store(in: &cancellables)
-
- userInfoManager.objectWillChange
- .sink { [weak self] _ in
- self?.objectWillChange.send()
- }
- .store(in: &cancellables)
+ // Initialize jamfInfoManager now that self exists
+ self.jamfInfoManager = JamfInfoManager(
+ jamfInfo: JamfInfo(lastCheckIn: "", lastInventory: "", url: "", jamfID: ""),
+ appStateManager: self
+ )
setupCardManager()
+
+ // Reload custom cards when CustomCardPath changes (Preferences picks up external `defaults write` too)
+ customCardPathObservation = observeChanges(
+ of: { [unowned self] in self.preferences.customCardPath.trimmingCharacters(in: .whitespacesAndNewlines) },
+ onChange: { [weak self] path in self?.customCardPathChanged(to: path) }
+ )
+ }
+
+ private func customCardPathChanged(to path: String) {
+ Logger.shared.logDebug("CustomCardPath changed -> '\(path)'")
+
+ // If path is empty, tear down any existing manager and clear cards
+ guard !path.isEmpty else {
+ jsonCardManager?.stopWatching()
+ jsonCardManager = nil
+ JsonCards.removeAll()
+ return
+ }
+
+ // Ensure a manager exists, stop any current watcher, then load and start watching the new path
+ if jsonCardManager == nil {
+ jsonCardManager = JsonCardManager(appState: self)
+ }
+ jsonCardManager?.stopWatching()
+ jsonCardManager?.loadFromFile(path)
+ jsonCardManager?.watchFile(path)
}
func startDemotionTimer(duration: TimeInterval) {
elevationManager.startDemotionTimer(duration: duration) { [weak self] remainingTime in
- DispatchQueue.main.async {
- guard let self = self else { return }
+ Task { @MainActor [weak self] in
+ guard let self else { return }
self.timeToDemote = remainingTime
self.isDemotionActive = remainingTime > 0
}
@@ -116,7 +196,9 @@ class AppStateManager: ObservableObject {
private func setupCardManager() {
guard !preferences.customCardPath.isEmpty else { return }
- jsonCardManager = JsonCardManager(appState: self)
+ if jsonCardManager == nil {
+ jsonCardManager = JsonCardManager(appState: self)
+ }
jsonCardManager?.loadFromFile(preferences.customCardPath)
jsonCardManager?.watchFile(preferences.customCardPath)
}
@@ -125,19 +207,81 @@ class AppStateManager: ObservableObject {
jsonCardManager?.loadFromFile(preferences.customCardPath)
}
- @MainActor
func refreshAll() {
isRefreshing = true
- Task {
+ Task { @MainActor [weak self] in
+ guard let self = self else { return }
await withTaskGroup(of: Void.self) { group in
- group.addTask { self.deviceInfoManager.refresh() }
- group.addTask { self.storageInfoManager.refresh() }
- group.addTask { self.mdmInfoManager.refresh() }
- group.addTask { self.systemUpdatesManager.refresh() }
- group.addTask { self.batteryInfoManager.refresh() }
- group.addTask { self.userInfoManager.refresh() }
+ group.addTask { @MainActor in await self.deviceInfoManager.refresh() }
+ group.addTask { @MainActor in self.storageInfoManager.refresh() }
+ group.addTask { @MainActor in self.mdmInfoManager.refresh() }
+ group.addTask { @MainActor in self.systemUpdatesManager.refresh() }
+ group.addTask { @MainActor in self.batteryInfoManager.refresh() }
+ group.addTask { @MainActor in self.userInfoManager.refresh() }
}
self.isRefreshing = false
}
}
+
+ /// Off unless an administrator sets `FleetNotifySignIn`. Leads with the failing count when the
+ /// ungated summary has one: what's wrong is the news, and signing in is how to see it.
+ private func notifyFleetSignInRequired() {
+ guard preferences.fleetNotifySignIn, preferences.mode == Constants.Modes.fleet else { return }
+ let failing = fleetDeviceManager.failingChecksCount ?? 0
+ let message = failing > 0
+ ? String(format: Constants.Fleet.signInNotificationFailing, failing)
+ : Constants.Fleet.signInNotification
+ NotificationService(appState: self).sendNotification(
+ message: message,
+ buttonText: Constants.Fleet.signIn,
+ command: "open supportcompanion://fleetsignin",
+ notificationType: .generic
+ )
+ }
+
+ private func notifyFleetPoliciesFailing(_ policies: [FleetPolicy]) {
+ guard preferences.fleetNotifyPolicies,
+ !preferences.hiddenCards.contains(Constants.Cards.fleetPolicies),
+ let first = policies.first else { return }
+ let message = policies.count == 1
+ ? String(format: Constants.Fleet.policyFailingNotification, first.name)
+ : String(format: Constants.Fleet.policiesFailingNotification, policies.count, first.name)
+ NotificationService(appState: self).sendNotification(
+ message: message,
+ buttonText: Constants.Fleet.viewDetails,
+ command: "open supportcompanion://home",
+ notificationType: .generic
+ )
+ }
+
+ private func notifyFleetActionFinished(_ title: FleetSoftwareTitle, action: FleetSoftwareTitle.Action, outcome: FleetSoftwareManager.ActionOutcome) {
+ guard preferences.fleetNotifyInstallResults else { return }
+ if outcome == .appOpen {
+ // Without a bundle identifier the app can't be found to quit it, so there's no button
+ let canQuit = !title.bundleIdentifiers.isEmpty
+ NotificationService(appState: self).sendNotification(
+ message: String(format: Constants.Fleet.appOpenNotification, title.title),
+ buttonText: canQuit ? Constants.Fleet.quitAndUpdate : nil,
+ command: canQuit ? "\(NotificationService.fleetQuitAndRetryCommand)\(title.id)" : nil,
+ openURL: "supportcompanion://apps",
+ notificationType: .generic
+ )
+ return
+ }
+ let succeeded = outcome == .succeeded
+ let format: String
+ switch (action, succeeded) {
+ case (.install, true): format = Constants.Fleet.installedNotification
+ case (.update, true): format = Constants.Fleet.updatedNotification
+ case (.reinstall, true): format = Constants.Fleet.reinstalledNotification
+ case (.uninstall, true): format = Constants.Fleet.uninstalledNotification
+ case (.uninstall, false): format = Constants.Fleet.uninstallFailedNotification
+ case (_, false): format = Constants.Fleet.installFailedNotification
+ }
+ NotificationService(appState: self).sendNotification(
+ message: String(format: format, title.title),
+ openURL: "supportcompanion://apps",
+ notificationType: .generic
+ )
+ }
}
diff --git a/SupportCompanion/ViewModels/ApplicationsInfoManager.swift b/SupportCompanion/ViewModels/ApplicationsInfoManager.swift
index 6f0df04..0500200 100644
--- a/SupportCompanion/ViewModels/ApplicationsInfoManager.swift
+++ b/SupportCompanion/ViewModels/ApplicationsInfoManager.swift
@@ -5,17 +5,20 @@
// Created by Tobias Almén on 2024-11-22.
//
+import AppKit
import Foundation
+import Observation
-class ApplicationsInfoManager: ObservableObject {
- private var monitorTask: Task?
- private var updateTimer: Timer?
+@MainActor
+@Observable
+final class ApplicationsInfoManager {
+ @ObservationIgnored private var monitorTask: Task?
private let munkiApps = MunkiApps()
private let intuneApps = IntuneApps()
private let profilerApps = SystemProfilerApplications()
private var appState: AppStateManager
- @Published var applicationInfo: InstalledApp = InstalledApp(
+ var applicationInfo: InstalledApp = InstalledApp(
id: UUID(),
name: "",
version: "",
@@ -24,36 +27,30 @@ class ApplicationsInfoManager: ObservableObject {
isSelfServe: false,
path: "",
type: "",
- bundleId: ""
+ bundleId: "",
+ iconUrl: "",
+ actionText: ""
)
init(appState: AppStateManager) {
self.appState = appState
}
- /// Starts the timer to fetch installed applications periodically
+ /// Starts periodic fetching of installed applications
func startMonitoring(interval: TimeInterval = 60.0) {
- // Stop any ongoing monitoring tasks or timers
stopMonitoring()
-
- // Start a task to fetch immediately based on the mode
monitorTask = Task {
await fetchAppsBasedOnMode()
- }
-
- // Create a timer to periodically fetch applications
- updateTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
- Task { [weak self] in
- guard let self = self else { return }
- await self.fetchAppsBasedOnMode()
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(interval))
+ guard !Task.isCancelled else { break }
+ await fetchAppsBasedOnMode()
}
}
}
- /// Stops the timer and cancels any ongoing tasks
+ /// Cancels any ongoing monitoring task
func stopMonitoring() {
- updateTimer?.invalidate()
- updateTimer = nil
monitorTask?.cancel()
monitorTask = nil
}
@@ -61,12 +58,17 @@ class ApplicationsInfoManager: ObservableObject {
/// Fetches installed applications based on the current mode
func fetchAppsBasedOnMode() async {
switch appState.preferences.mode {
- case Constants.modes.munki:
+ case Constants.Modes.munki:
await getInstalledMunkiApps()
- case Constants.modes.intune:
+ case Constants.Modes.intune:
await getInstalledIntuneApps()
- case Constants.modes.systemProfiler:
+ case Constants.Modes.systemProfiler:
await getInstalledProfilerApps()
+ case Constants.Modes.jamf:
+ await getInstalledJamfApps()
+ case Constants.Modes.fleet:
+ // Fleet mode shows FleetAppsView, backed by FleetSoftwareManager
+ break
default:
await getInstalledMunkiApps()
}
@@ -97,6 +99,7 @@ class ApplicationsInfoManager: ObservableObject {
command = "open munki://detail-\(commandName)"
}
+ let munkiIconPath = "/Library/Managed Installs/icons/\(name).png"
return InstalledApp(
id: UUID(),
name: name,
@@ -106,54 +109,95 @@ class ApplicationsInfoManager: ObservableObject {
isSelfServe: isSelfServe,
path: "",
type: "",
- bundleId: ""
+ bundleId: "",
+ iconUrl: "",
+ actionText: Constants.General.manage,
+ iconPath: FileManager.default.fileExists(atPath: munkiIconPath) ? munkiIconPath : nil
)
}
let sortedApps = installedApps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
- DispatchQueue.main.async {
- self.appState.installedApplications = sortedApps
- }
+ appState.installedApplications = sortedApps
}
}
-
+
func getInstalledIntuneApps() async {
+ let apps = await intuneApps.getInstalledAppsListFromLog()
+ let installedApps = apps.compactMap { app -> InstalledApp? in
+ guard
+ let info = app["Info"] as? [String: Any],
+ let name = info["AppName"] as? String
+ else {
+ return nil
+ }
+
+ var version = info["Version"] as? String ?? ""
+ let type = info["AppType"] as? String ?? ""
+ let bundleId = info["BundleID"] as? String ?? ""
+
+ // Prefer the installed bundle's version and icon over what the log reports
+ var iconPath: String?
+ if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
+ let appInfoPlistPath = "\(appURL.path)/Contents/Info.plist"
+ version = getAppVersion(plistPath: appInfoPlistPath) ?? "Unknown"
+ iconPath = getIconPath(plistPath: appInfoPlistPath, appPath: appURL.path)
+ }
+
+ return InstalledApp(
+ id: UUID(),
+ name: name,
+ version: version,
+ action: "",
+ arch: "",
+ isSelfServe: false,
+ path: "",
+ type: type,
+ bundleId: bundleId,
+ iconUrl: "",
+ actionText: "",
+ iconPath: iconPath
+ )
+ }
+
+ let sortedApps = installedApps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ appState.installedApplications = sortedApps
+ }
+
+ func getInstalledJamfApps() async {
do {
- let apps = await intuneApps.getInstalledAppsListFromLog()
- let installedApps = apps.compactMap { app -> InstalledApp? in
- guard
- let info = app["Info"] as? [String: Any],
- let name = info["AppName"] as? String
- else {
+ let apps = await getInstalledJamfAppsFromStore()
+ let installedApps = apps.compactMap { (key: AnyHashable, value: Any) -> InstalledApp? in
+ guard let dict = value as? [String: Any] else { return nil }
+ guard let name = dict["name"] as? String,
+ let version = dict["version"] as? String else {
return nil
}
+ let iconUrl = dict["iconUrl"] as? String ?? ""
+ let id = dict["id"] as? Int ?? 0
+ let command = "open \"selfservicecapability://content?entity=policy&id=\(id)&action=execute\""
+ let actionText = (dict["postInstallText"] as? String) ?? ""
- let version = info["Version"] as? String ?? ""
- let type = info["AppType"] as? String ?? ""
- let bundleId = info["BundleID"] as? String ?? ""
-
return InstalledApp(
id: UUID(),
name: name,
version: version,
- action: "",
+ action: command,
arch: "",
- isSelfServe: false,
+ isSelfServe: true,
path: "",
- type: type,
- bundleId: bundleId
+ type: "",
+ bundleId: "",
+ iconUrl: iconUrl,
+ actionText: actionText
)
}
let sortedApps = installedApps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
-
- DispatchQueue.main.async {
- self.appState.installedApplications = sortedApps
- }
+ appState.installedApplications = sortedApps
}
}
-
+
func getInstalledProfilerApps() async {
do {
let apps = await profilerApps.getInstalledApps()
@@ -168,6 +212,7 @@ class ApplicationsInfoManager: ObservableObject {
]
let arch = archMap[app["arch_kind"] as? String ?? ""] ?? "Unknown"
+ let path = app["path"] as? String ?? ""
return InstalledApp(
id: UUID(),
@@ -176,17 +221,18 @@ class ApplicationsInfoManager: ObservableObject {
action: "",
arch: arch,
isSelfServe: false,
- path: app["path"] as? String ?? "",
+ path: path,
type: "",
- bundleId: ""
+ bundleId: "",
+ iconUrl: "",
+ actionText: "",
+ iconPath: getIconPath(plistPath: "\(path)/Contents/Info.plist", appPath: path)
)
}
let sortedApps = installedApps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
-
- DispatchQueue.main.async {
- self.appState.installedApplications = sortedApps
- }
+ appState.installedApplications = sortedApps
}
}
}
+
diff --git a/SupportCompanion/ViewModels/BatteryInfoManager.swift b/SupportCompanion/ViewModels/BatteryInfoManager.swift
index 5dbc676..e7f8e38 100644
--- a/SupportCompanion/ViewModels/BatteryInfoManager.swift
+++ b/SupportCompanion/ViewModels/BatteryInfoManager.swift
@@ -6,9 +6,12 @@
//
import Foundation
+import Observation
-class BatteryInfoManager: ObservableObject {
- private var monitorTask: Task?
+@MainActor
+@Observable
+class BatteryInfoManager {
+ @ObservationIgnored private var monitorTask: Task?
static let shared = BatteryInfoManager(
batteryInfo: BatteryInfo(
@@ -17,12 +20,12 @@ class BatteryInfoManager: ObservableObject {
maxCapacity: 0,
cycleCount: 0,
isCharging: "",
- temperature: 0,
+ temperature: nil,
timeToFull: ""
)
)
- @Published var batteryInfo: BatteryInfo
+ var batteryInfo: BatteryInfo
init(batteryInfo: BatteryInfo) {
self.batteryInfo = batteryInfo
@@ -34,17 +37,15 @@ class BatteryInfoManager: ObservableObject {
func updateBatteryInfo() {
// Ensure all updates happen on the main thread
- DispatchQueue.main.async {
- self.batteryInfo = BatteryInfo(
- id: UUID(),
- designCapacity: getBatteryDesignCapacity() ?? 0,
- maxCapacity: getBatteryMaxCapacity() ?? 0,
- cycleCount: getBatteryCycleCount() ?? 0,
- isCharging: isBatteryCharging(),
- temperature: getBatteryTemperature() ?? 0,
- timeToFull: getBatteryTimeRemaining()
- )
- }
+ self.batteryInfo = BatteryInfo(
+ id: UUID(),
+ designCapacity: getBatteryDesignCapacity() ?? 0,
+ maxCapacity: getBatteryMaxCapacity() ?? 0,
+ cycleCount: getBatteryCycleCount() ?? 0,
+ isCharging: isBatteryCharging(),
+ temperature: getBatteryTemperature(),
+ timeToFull: getBatteryTimeRemaining()
+ )
}
/// Starts monitoring battery properties and updates the model.
@@ -53,20 +54,7 @@ class BatteryInfoManager: ObservableObject {
Logger.shared.logDebug("Starting battery monitoring")
monitorTask = Task {
while !Task.isCancelled {
- // Update the model on the main thread
- await MainActor.run {
- self.batteryInfo = BatteryInfo(
- id: UUID(),
- designCapacity: getBatteryDesignCapacity() ?? 0,
- maxCapacity: getBatteryMaxCapacity() ?? 0,
- cycleCount: getBatteryCycleCount() ?? 0,
- isCharging: isBatteryCharging(),
- temperature: getBatteryTemperature() ?? 0,
- timeToFull: getBatteryTimeRemaining()
- )
- }
-
- // Wait for the specified interval before fetching data again
+ updateBatteryInfo()
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
}
}
diff --git a/SupportCompanion/ViewModels/CardGridViewModel.swift b/SupportCompanion/ViewModels/CardGridViewModel.swift
index 2559a0e..e31106b 100644
--- a/SupportCompanion/ViewModels/CardGridViewModel.swift
+++ b/SupportCompanion/ViewModels/CardGridViewModel.swift
@@ -1,16 +1,17 @@
import Foundation
+import Observation
import Combine
import SwiftUI
-class CardGridViewModel: ObservableObject {
- @Published var toastConfig: ToastConfig?
+@MainActor
+@Observable
+class CardGridViewModel {
+ var toastConfig: ToastConfig?
private let appState: AppStateManager
private let munkiApps = MunkiApps()
- var installPercentageTimer: Timer?
- var pendingAppsTimer: Timer?
- private var fetchTask: Task?
- private var isTaskRunning = false
- private var isPendingAppsTaskRunning = false
+ @ObservationIgnored private var fetchTask: Task?
+ @ObservationIgnored private var isTaskRunning = false
+ @ObservationIgnored private var isPendingAppsTaskRunning = false
init(appState: AppStateManager) {
self.appState = appState
@@ -36,11 +37,14 @@ class CardGridViewModel: ObservableObject {
("OS Version:", appState.deviceInfoManager.deviceInfo?.osVersion ?? ""),
("OS Build:", appState.deviceInfoManager.deviceInfo?.osBuild ?? ""),
("IP Address:", appState.deviceInfoManager.deviceInfo?.ipAddress ?? ""),
+ ("WiFi SSID:", appState.deviceInfoManager.deviceInfo?.ssid ?? ""),
("Last Reboot:", "\(appState.deviceInfoManager.deviceInfo?.lastRestartDays ?? 0) days"),
("--------------------- Battery ---------------------", ""),
("Health:", "\(healthPercentage)%"),
("Cycle Count:", appState.batteryInfoManager.batteryInfo.cycleCount),
- ("Temperature:", "\((String(format: "%.1f", appState.batteryInfoManager.batteryInfo.temperature)))°C"),
+ ("Temperature:", appState.batteryInfoManager.batteryInfo.temperature.map {
+ String(format: "%.1f%@", $0, Locale.current.measurementSystem == .metric ? "°C" : "°F")
+ } ?? "N/A"),
("--------------------- Storage ---------------------", ""),
("Used:", "\(appState.storageInfoManager.storageInfo.usage)%"),
("FileVault:", appState.storageInfoManager.storageInfo.fileVault ? "Enabled" : "Disabled"),
@@ -49,49 +53,46 @@ class CardGridViewModel: ObservableObject {
}
func createRestartIntuneAgentButton(fontSize: CGFloat? = nil) -> ScButton {
- ScButton(Constants.Actions.restartIntuneAgent, fontSize: fontSize) {
- ActionHelpers.restartIntuneAgent { result in
+ ScButton(Constants.Actions.restartIntuneAgent, fontSize: fontSize) { [weak self] in
+ ActionHelpers.restartIntuneAgent { [weak self] result in
ActionHelpers.handleResult(
operationName: "Restart Intune Agent",
result: result,
successMessage: "Intune agent was restarted successfully.",
- //errorMessage: "Failed to restart Intune agent",
- updateToast: { toast in
- DispatchQueue.main.async {
- self.toastConfig = toast
- }
+ updateToast: { [weak self] toast in
+ Task { @MainActor [weak self] in self?.toastConfig = toast }
}
)
}
}
}
-
+
func createGatherLogsButton(fontSize: CGFloat? = nil) -> ScButton {
- ScButton(Constants.Actions.gatherLogs, fontSize: fontSize) {
- ActionHelpers.gatherLogs(preferences: self.appState.preferences) { result in
+ ScButton(Constants.Actions.gatherLogs, fontSize: fontSize) { [weak self] in
+ guard let self else { return }
+ let preferences = await self.appState.preferences
+ await ActionHelpers.gatherLogs(preferences: preferences) { [weak self] result in
ActionHelpers.handleResult(
operationName: Constants.Actions.gatherLogs,
result: result,
successMessage: Constants.ToastMessages.SuccessMessages.gatherLogsSuccess,
- updateToast: { toast in
- DispatchQueue.main.async {
- self.toastConfig = toast
- }
+ updateToast: { [weak self] toast in
+ Task { @MainActor [weak self] in self?.toastConfig = toast }
}
)
}
}
}
-
+
func createRebootButton(
onShowModal: @escaping (Int, String, String) -> Void
) -> ScButton {
ScButton(Constants.Actions.reboot) {
await ActionHelpers.reboot { result in
- DispatchQueue.main.async {
+ Task { @MainActor in
switch result {
case .info(let message):
- onShowModal(Constants.RebootModal.countdown, Constants.RebootModal.title, message) // Trigger modal
+ onShowModal(Constants.RebootModal.countdown, Constants.RebootModal.title, message)
case .failure(let error):
Logger.shared.logError("Reboot failed: \(error.localizedDescription)")
default:
@@ -103,49 +104,32 @@ class CardGridViewModel: ObservableObject {
}
func createChangePasswordButton(fontSize: CGFloat? = nil) -> ScButton {
- ScButton(Constants.Actions.changePassword, fontSize: fontSize) {
- await ActionHelpers.openChangePassword(preferences: self.appState.preferences) { result in
+ ScButton(Constants.Actions.changePassword, fontSize: fontSize) { [weak self] in
+ guard let self else { return }
+ await ActionHelpers.openChangePassword(preferences: self.appState.preferences) { [weak self] result in
ActionHelpers.handleResult(
operationName: Constants.Actions.changePassword,
result: result,
successMessage: "",
- updateToast: { toast in
- DispatchQueue.main.async {
- self.toastConfig = toast
- }
+ updateToast: { [weak self] toast in
+ Task { @MainActor [weak self] in self?.toastConfig = toast }
}
)
}
}
}
-
+
enum ManagementAppURLType {
case update
case `default`
}
func createOpenManagementAppButton(type: ManagementAppURLType, fontSize: CGFloat? = nil) -> ScButton {
- let appName: String
- let appURL: String
-
- switch appState.preferences.mode {
- case Constants.modes.munki:
- if type == .update {
- appName = "MSC Updates"
- appURL = Constants.AppPaths.MSCUpdates
- } else {
- appName = "MSC"
- appURL = Constants.AppPaths.MSC
- }
- case Constants.modes.intune:
- appName = "Company Portal"
- appURL = Constants.AppPaths.companyPortal
- default:
- appName = "Unknown App"
- appURL = ""
- }
+ let manager = appState.activeUpdatesManager
+ let appURL = manager?.managementApp(forUpdates: type == .update).path ?? ""
+ let title = manager?.openManagementAppTitle(forUpdates: type == .update) ?? "\(Constants.Actions.openManagementApp) Unknown App"
- return ScButton("\(Constants.Actions.openManagementApp) \(appName)", fontSize: fontSize) {
+ return ScButton(title, fontSize: fontSize) {
ActionHelpers.openManagementApp(appURL: appURL)
}
}
@@ -159,25 +143,20 @@ class CardGridViewModel: ObservableObject {
pasteboard.clearContents()
let didWrite = pasteboard.setString(clipboardContent, forType: .string)
- if didWrite, let retrievedContent = pasteboard.string(forType: .string) {
- DispatchQueue.main.async {
- self.toastConfig = ToastConfig(
- isShowing: true,
- type: .complete(.green),
- title: "Success!",
- subTitle: "Device info copied to clipboard."
- )
- }
- }
- else {
- DispatchQueue.main.async {
- self.toastConfig = ToastConfig(
- isShowing: true,
- type: .error(.red),
- title: "Error!",
- subTitle: "Failed to copy device info."
- )
- }
+ if didWrite, let _ = pasteboard.string(forType: .string) {
+ toastConfig = ToastConfig(
+ isShowing: true,
+ type: .complete(.green),
+ title: "Success!",
+ subTitle: "Device info copied to clipboard."
+ )
+ } else {
+ toastConfig = ToastConfig(
+ isShowing: true,
+ type: .error(.red),
+ title: "Error!",
+ subTitle: "Failed to copy device info."
+ )
}
}
@@ -185,6 +164,8 @@ class CardGridViewModel: ObservableObject {
Task {
do {
_ = try await ExecutionService.executeCommand("open", with: [Constants.Panels.storage])
+ } catch {
+ Logger.shared.logError("Failed to open storage panel: \(error)")
}
}
}
@@ -194,7 +175,12 @@ class CardGridViewModel: ObservableObject {
}
// MARK: - Preferences Management
-
+
+ /// True when the configured mode has a pending-updates manager (Munki, Intune, or Jamf).
+ var hasManagementMode: Bool {
+ appState.activeUpdatesManager != nil
+ }
+
func isCardVisible(_ card: String) -> Bool {
!appState.preferences.hiddenCards.contains(card)
}
@@ -203,4 +189,88 @@ class CardGridViewModel: ObservableObject {
func isButtonVisible(_ button: String) -> Bool {
!appState.preferences.hiddenActions.contains(button)
}
+
+ var isUpdating = false
+
+ func runJamfUpdate(forId: String) async {
+ isUpdating = true
+ defer { isUpdating = false }
+
+ // Kick off the update; ideally obtain a process handle or PID
+ do {
+ _ = try await ExecutionService.jamfPatch(id: forId)
+ } catch {
+ // Log and bail
+ Logger.shared.logError("jamf patch launch failed: \(error)")
+ return
+ }
+
+ // Poll conservatively with delay; add timeout
+ let pattern = "jamf patch -id \(forId)"
+ let deadline = Date().addingTimeInterval(600) // 10 min timeout
+
+ while Date() < deadline && !Task.isCancelled {
+ do {
+ let result = try await ExecutionService.executeCommand(
+ "/usr/bin/pgrep",
+ with: ["-lf", pattern]
+ )
+ if result.isEmpty {
+ break // process no longer running
+ }
+ } catch {
+ // If pgrep errors, consider breaking or retrying a few times
+ Logger.shared.logError("pgrep error: \(error)")
+ }
+
+ try? await Task.sleep(for: .seconds(0.5))
+ }
+ }
+
+ func getVisibleStacks(viewModel: CardGridViewModel) -> [(id: String, view: AnyView)] {
+ var visibleStacks: [(id: String, view: AnyView)] = []
+
+ // Conditional logic to arrange Battery and Storage/Device stacks
+ if viewModel.isCardVisible(Constants.Cards.storage) && viewModel.isCardVisible(Constants.Cards.deviceManagement) {
+ // Both Storage and Device Management are visible: Split columns
+ visibleStacks.append(
+ (id: "StorageDeviceManagement",
+ view: AnyView(
+ StorageDeviceManagementStack(viewModel: viewModel)
+ .frame(maxWidth: .infinity)
+ .gridCellColumns(1)
+ ))
+ )
+
+ visibleStacks.append(
+ (id: "BatteryEvergreen",
+ view: AnyView(
+ BatteryEvergreenStack(viewModel: viewModel)
+ .frame(maxWidth: .infinity)
+ .gridCellColumns(1)
+ ))
+ )
+ } else {
+ // Otherwise, span the grid
+ visibleStacks.append(
+ (id: "BatteryEvergreen",
+ view: AnyView(
+ BatteryEvergreenStack(viewModel: viewModel)
+ .frame(maxWidth: .infinity)
+ .gridCellColumns(2)
+ ))
+ )
+
+ visibleStacks.append(
+ (id: "StorageDeviceManagement",
+ view: AnyView(
+ StorageDeviceManagementStack(viewModel: viewModel)
+ .frame(maxWidth: .infinity)
+ .gridCellColumns(2)
+ ))
+ )
+ }
+
+ return visibleStacks
+ }
}
diff --git a/SupportCompanion/ViewModels/DeviceInfoManager.swift b/SupportCompanion/ViewModels/DeviceInfoManager.swift
index 209bcbb..16eeca8 100644
--- a/SupportCompanion/ViewModels/DeviceInfoManager.swift
+++ b/SupportCompanion/ViewModels/DeviceInfoManager.swift
@@ -6,11 +6,13 @@
//
import Foundation
+import Observation
import Combine
-class DeviceInfoManager: ObservableObject {
- //private var timer: AnyCancellable?
- private var timer: Timer?
+@MainActor
+@Observable
+class DeviceInfoManager {
+ @ObservationIgnored private var monitorTask: Task?
static let shared = DeviceInfoManager(
deviceInfo: DeviceInfo(
@@ -21,6 +23,7 @@ class DeviceInfoManager: ObservableObject {
cpuType: "",
ram: "",
ipAddress: "",
+ ssid: nil,
serialNumber: "",
lastRestart: 0,
lastRestartDays: 0,
@@ -28,7 +31,7 @@ class DeviceInfoManager: ObservableObject {
)
)
- @Published var deviceInfo: DeviceInfo? = nil
+ var deviceInfo: DeviceInfo? = nil
init(deviceInfo: DeviceInfo) {
self.deviceInfo = deviceInfo
@@ -36,72 +39,73 @@ class DeviceInfoManager: ObservableObject {
func startMonitoring() {
Logger.shared.logDebug("Starting device info monitoring")
- timer?.invalidate()
- refresh()
- timer = Timer.scheduledTimer(withTimeInterval: 300, repeats: true) { _ in
- self.refresh()
+ stopMonitoring()
+
+ IPAddressMonitor.startMonitoring { @MainActor [weak self] status in
+ guard let self, let currentDeviceInfo = self.deviceInfo else { return }
+ let updatedIPAddress = status.ipAddresses.joined(separator: ", ")
+ self.deviceInfo = DeviceInfo(
+ id: currentDeviceInfo.id,
+ hostName: currentDeviceInfo.hostName,
+ osVersion: currentDeviceInfo.osVersion,
+ osBuild: currentDeviceInfo.osBuild,
+ cpuType: currentDeviceInfo.cpuType,
+ ram: currentDeviceInfo.ram,
+ ipAddress: updatedIPAddress,
+ ssid: status.ssid,
+ serialNumber: currentDeviceInfo.serialNumber,
+ lastRestart: currentDeviceInfo.lastRestart,
+ lastRestartDays: currentDeviceInfo.lastRestartDays,
+ model: currentDeviceInfo.model
+ )
+ }
+
+ monitorTask = Task {
+ await refresh()
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(300))
+ guard !Task.isCancelled else { break }
+ await refresh()
+ }
}
}
-
+
func stopMonitoring() {
Logger.shared.logDebug("Stopping device info monitoring")
- timer?.invalidate()
+ monitorTask?.cancel()
+ IPAddressMonitor.stopMonitoring()
+ monitorTask = nil
}
- func refresh() {
- DispatchQueue.main.async {
- let currentIPAddress = getAllIPAddresses().joined(separator: ", ")
- self.deviceInfo = DeviceInfo(
- id: UUID(),
- hostName: fetchComputerName(),
- osVersion: getOSVersion(),
- osBuild: getOSBuild(),
- cpuType: getCPUName() ?? "",
- ram: getRAMSize(),
- ipAddress: currentIPAddress,
- serialNumber: getSerialNumber(),
- lastRestart: getLastRestartMinutes() ?? 0,
- lastRestartDays: getLastRebootDays() ?? 0,
- model: getModelName()
- )
-
- guard let lastRebootDays = self.deviceInfo?.lastRestartDays else {
- return
- }
- if lastRebootDays >= AppStateManager.shared.preferences.rebootReminderDays && AppStateManager.shared.preferences.rebootReminderDays > 0 {
- let dayWord = lastRebootDays == 1 ? Constants.General.day : Constants.General.days
- let message = String(format: Constants.Notifications.Reboot.RebootMessage, lastRebootDays, dayWord.lowercased())
-
- NotificationService(appState: AppStateManager.shared).sendNotification(
- message: message,
- buttonText: "",
- command: "",
- notificationType: .rebootReminder
- )
- }
- }
-
- IPAddressMonitor.startMonitoring { newIPAddresses in
- DispatchQueue.main.async {
- guard let currentDeviceInfo = self.deviceInfo else {
- return // Exit early if `self.deviceInfo` is nil
- }
+ func refresh() async {
+ let currentIPAddress = getAllIPAddresses().joined(separator: ", ")
+ deviceInfo = DeviceInfo(
+ id: UUID(),
+ hostName: fetchComputerName(),
+ osVersion: getOSVersion(),
+ osBuild: getOSBuild(),
+ cpuType: getCPUName() ?? "",
+ ram: getRAMSize(),
+ ipAddress: currentIPAddress,
+ ssid: await getSSID(),
+ serialNumber: getSerialNumber(),
+ lastRestart: getLastRestartMinutes() ?? 0,
+ lastRestartDays: getLastRebootDays() ?? 0,
+ model: getModelName()
+ )
- let updatedIPAddress = newIPAddresses.joined(separator: ", ")
- self.deviceInfo = DeviceInfo(
- id: currentDeviceInfo.id,
- hostName: currentDeviceInfo.hostName,
- osVersion: currentDeviceInfo.osVersion,
- osBuild: currentDeviceInfo.osBuild,
- cpuType: currentDeviceInfo.cpuType,
- ram: currentDeviceInfo.ram,
- ipAddress: updatedIPAddress,
- serialNumber: currentDeviceInfo.serialNumber,
- lastRestart: currentDeviceInfo.lastRestart,
- lastRestartDays: currentDeviceInfo.lastRestartDays,
- model: currentDeviceInfo.model
- )
- }
+ if let lastRebootDays = deviceInfo?.lastRestartDays,
+ lastRebootDays >= AppStateManager.shared.preferences.notifications.rebootReminderDays,
+ AppStateManager.shared.preferences.notifications.rebootReminderDays > 0 {
+ let dayWord = lastRebootDays == 1 ? Constants.General.day : Constants.General.days
+ let message = String(format: Constants.Notifications.Reboot.RebootMessage, lastRebootDays, dayWord.lowercased())
+ NotificationService(appState: AppStateManager.shared).sendNotification(
+ message: message,
+ buttonText: "",
+ command: "",
+ notificationType: .rebootReminder
+ )
}
}
}
+
diff --git a/SupportCompanion/ViewModels/ElevationManager.swift b/SupportCompanion/ViewModels/ElevationManager.swift
index ff4f39f..6ac201f 100644
--- a/SupportCompanion/ViewModels/ElevationManager.swift
+++ b/SupportCompanion/ViewModels/ElevationManager.swift
@@ -2,8 +2,15 @@ import Foundation
import Combine
import SwiftUI
+/// Drives the elevation UI.
+///
+/// The helper owns temporary administrator rights: it decides whether elevation is allowed, records the
+/// deadline in a root-owned file, and takes the rights back when the time is up. The timer here only
+/// drives the countdown and the halfway notification, so quitting the app no longer leaves the user an
+/// administrator — it just stops the display.
+@MainActor
class ElevationManager {
- @State private var elevationReason = ""
+ private var elevationReason = ""
private var appState: AppStateManager
private var cancellable: AnyCancellable?
private var timerPublisher: AnyPublisher?
@@ -12,21 +19,20 @@ class ElevationManager {
static let shared = ElevationManager(appState: AppStateManager.shared)
init(appState: AppStateManager) {
- self.appState = AppStateManager.shared
+ self.appState = appState
}
- func elevatePrivileges(completion: @escaping (Bool) -> Void) {
- authenticateWithTouchIDOrPassword(completion: { success in
+ func elevatePrivileges(reason: String, completion: @escaping (Bool) -> Void) {
+ authenticateWithTouchIDOrPassword(completion: { success in
guard success else {
completion(false)
return
}
- let command = "/usr/sbin/dseditgroup"
- let arguments = ["-o", "edit", "-a", NSUserName(), "-t", "user", "admin"]
Task {
do {
- _ = try await ExecutionService.executeCommandPrivileged(command, arguments: arguments)
- // Update isAdmin status
+ // The helper re-checks EnableElevation before doing anything, and throws if an
+ // administrator has not turned elevation on.
+ _ = try await ExecutionService.elevate(reason: reason)
UserInfoManager.shared.updateUserInfo()
completion(true)
}
@@ -39,29 +45,38 @@ class ElevationManager {
}
func demotePrivileges(completion: @escaping (Bool) -> Void) {
- let command = "/usr/sbin/dseditgroup"
- let arguments = ["-o", "edit", "-d", NSUserName(), "-t", "user", "admin"]
Task {
do {
- _ = try await ExecutionService.executeCommandPrivileged(command, arguments: arguments)
+ _ = try await ExecutionService.demote()
// Update isAdmin status
UserInfoManager.shared.updateUserInfo()
- UserDefaults.standard.removeObject(forKey: "PrivilegeDemotionEndTime")
completion(true)
}
catch {
Logger.shared.logError("Failed to demote privileges: \(error.localizedDescription)")
+
+ // The callers stop the countdown before asking, so a failure here would otherwise
+ // leave an administrator looking demoted with no way back to the button.
+ await resyncDemotionTimer()
completion(false)
}
}
}
+ /// Seconds until the helper demotes the user, as the helper sees it.
+ func remainingElevationTime() async -> TimeInterval {
+ do {
+ return try await ExecutionService.elevationTimeRemaining()
+ } catch {
+ Logger.shared.logError("Failed to read elevation time remaining: \(error.localizedDescription)")
+ return 0
+ }
+ }
+
func startDemotionTimer(duration: TimeInterval, onUpdate: @escaping (Double) -> Void) {
Logger.shared.logDebug("Starting demotion timer with duration: \(duration)")
stopDemotionTimer() // Ensure any existing timer is stopped
- persistDemotionState(endTime: Date().addingTimeInterval(duration))
-
NotificationService(appState: self.appState).sendNotification(
message: "\(Constants.Notifications.Elevation.ElevationStartedMessage) \(duration.formattedTimeUnit()).",
notificationType: .generic
@@ -95,17 +110,17 @@ class ElevationManager {
self.onTimeUpdate?(remainingTime)
} else {
self.stopDemotionTimer()
- self.demotePrivileges { success in
- guard success else {
- Logger.shared.logDebug("Failed to demote privileges.")
- return
- }
+
+ // The helper demotes on its own schedule; catch up with what it did rather than
+ // asking for a second demotion.
+ Task { @MainActor in
+ UserInfoManager.shared.updateUserInfo()
NotificationService(appState: self.appState).sendNotification(
message: Constants.Notifications.Elevation.ElevationDemotedMessage,
notificationType: .generic
)
}
- Logger.shared.logDebug("Demotion timer expired. Privileges demoted.")
+ Logger.shared.logDebug("Demotion timer expired.")
}
}
}
@@ -114,37 +129,66 @@ class ElevationManager {
func stopDemotionTimer() {
cancellable?.cancel()
cancellable = nil
+ timerPublisher = nil
+
+ let onTimeUpdate = self.onTimeUpdate
+ // Released before the last call rather than after, so a cancelled countdown has no way to
+ // write a value again even if something still holds a reference to its closure.
+ self.onTimeUpdate = nil
+
onTimeUpdate?(0) // Notify remaining time is 0
}
- func handleElevation(reason: String) {
+ /// Put the countdown back in step with the helper, which is the only authority on it.
+ ///
+ /// Used when a demotion did not happen after all: the UI has already been told the countdown is
+ /// over, and leaving it that way would show someone as demoted while they still hold rights.
+ func resyncDemotionTimer() async {
+ let remaining = await remainingElevationTime()
+
+ if remaining > 0 {
+ appState.startDemotionTimer(duration: remaining)
+ } else {
+ appState.stopDemotionTimer()
+ }
+ }
+
+ /// Elevate, and tell the caller how it went once the user has answered the authentication prompt.
+ ///
+ /// `completion` runs on the main actor, after rights are granted and the countdown has started, so
+ /// a caller that wants to do something with those rights knows when they exist. It is optional
+ /// because most callers are a button that has nothing left to do.
+ func handleElevation(reason: String, completion: (@MainActor (Bool) -> Void)? = nil) {
Logger.shared.logDebug("Handling elevation for reason: \(reason)")
// Authenticate and elevate privileges
- self.elevatePrivileges { success in
- guard success else {
- Logger.shared.logDebug("Authentication failed. Unable to elevate privileges.")
- return
- }
- Logger.shared.logDebug("Authentication successful. Privileges elevated.")
- if self.appState.preferences.requireReasonForElevation {
- if !self.appState.preferences.elevationWebhookURL.isEmpty {
- sendReasonToWebhook(reason: reason)
- } else {
- saveReasonToDisk(reason: reason)
+ self.elevatePrivileges(reason: reason) { success in
+ Task { @MainActor [weak self] in
+ guard let self else {
+ // Nothing below this point runs if the manager has been released, which is why
+ // callers must use the shared instance rather than one of their own.
+ Logger.shared.logError("Elevation finished after its manager was released; the countdown was not started")
+ completion?(false)
+ return
+ }
+ guard success else {
+ Logger.shared.logDebug("Authentication failed or elevation was refused.")
+ completion?(false)
+ return
+ }
+ Logger.shared.logDebug("Privileges elevated.")
+ if self.appState.preferences.elevation.requireReasonForElevation {
+ if !self.appState.preferences.elevation.elevationWebhookURL.isEmpty {
+ sendReasonToWebhook(reason: reason)
+ } else {
+ saveReasonToDisk(reason: reason)
+ }
}
+ // Count down from what the helper actually granted, not from what we asked for
+ let duration = await self.remainingElevationTime()
+ self.appState.startDemotionTimer(duration: duration)
+
+ completion?(true)
}
- // Start the timer
- let duration = Double(self.appState.preferences.maxElevationTime * 60)
- self.appState.startDemotionTimer(duration: duration)
}
}
-
- func persistDemotionState(endTime: Date) {
- UserDefaults.standard.set(endTime, forKey: "PrivilegeDemotionEndTime")
- UserDefaults.standard.synchronize()
- }
-
- func loadPersistedDemotionState() -> Date? {
- return UserDefaults.standard.object(forKey: "PrivilegeDemotionEndTime") as? Date
- }
}
diff --git a/SupportCompanion/ViewModels/EvergreenInfoManager.swift b/SupportCompanion/ViewModels/EvergreenInfoManager.swift
index 5a7f0ad..b089448 100644
--- a/SupportCompanion/ViewModels/EvergreenInfoManager.swift
+++ b/SupportCompanion/ViewModels/EvergreenInfoManager.swift
@@ -6,8 +6,11 @@
//
import Foundation
+import Observation
-class EvergreenInfoManager: ObservableObject {
+@MainActor
+@Observable
+class EvergreenInfoManager {
private var evergreenHelper = EvergreenHelpers()
private var appState: AppStateManager
@@ -23,8 +26,6 @@ class EvergreenInfoManager: ObservableObject {
func updateEvergreenInfo() async {
let catalogs = await evergreenHelper.getCatalogs()
- DispatchQueue.main.async {
- self.appState.catalogs = Array(Set(catalogs))
- }
+ appState.catalogs = Array(Set(catalogs))
}
}
diff --git a/SupportCompanion/ViewModels/FleetDeviceManager.swift b/SupportCompanion/ViewModels/FleetDeviceManager.swift
new file mode 100644
index 0000000..feb9949
--- /dev/null
+++ b/SupportCompanion/ViewModels/FleetDeviceManager.swift
@@ -0,0 +1,236 @@
+//
+// FleetDeviceManager.swift
+// SupportCompanion
+//
+// This Mac's Fleet host details and policies, for the Fleet and Device Compliance cards, failing policy
+// notifications and the Refetch action. Both cards come from one request to `GET /device/{token}`.
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class FleetDeviceManager {
+ enum LoadState: Equatable {
+ case idle
+ case loaded
+ /// Fleet Desktop SSO is enabled and the user hasn't signed in.
+ case ssoRequired
+ case failed(String)
+ }
+
+ private(set) var host: FleetHost?
+ /// The ungated `/desktop` summary. Fleet keeps this outside the SSO gate deliberately — it carries
+ /// no identifying detail — so it's what's left to show when the user hasn't signed in.
+ private(set) var summary: FleetDesktopSummary?
+ private(set) var loadState: LoadState = .idle
+ private(set) var lastUpdated: Date?
+ /// A refetch was requested and Fleet hasn't received the Mac's fresh details yet.
+ private(set) var isRefetching = false
+ /// Why the last re-check couldn't be requested.
+ private(set) var refetchError: String?
+
+ var policies: [FleetPolicy] { host?.policies ?? [] }
+
+ /// Whether Fleet is withholding this Mac's record until the user signs in.
+ var isSignedOut: Bool { loadState == .ssoRequired && host == nil }
+
+ /// Failing checks from the host record, or from the ungated summary while signed out, so the
+ /// badge and notifications stay truthful either way. Nil when even the summary is unavailable.
+ var failingChecksCount: Int? {
+ if host != nil { return failingPolicies.count }
+ return summary?.failingPoliciesCount
+ }
+
+ /// Rows for the Fleet card.
+ var infoRows: [(key: String, display: String, value: InfoValue)] {
+ typealias Info = Constants.FleetInfo
+ let unknown = Info.unknown
+ func relative(_ date: Date?) -> String {
+ guard host != nil else { return unknown }
+ return FleetHost.realDate(date).map { $0.relativeDescription() } ?? Info.never
+ }
+ return [
+ (Info.Keys.id, Info.Labels.id, .string(host.map { String($0.id) } ?? unknown)),
+ (Info.Keys.team, Info.Labels.team, .string(host.map { $0.team ?? Info.noTeam } ?? unknown)),
+ (Info.Keys.lastSeen, Info.Labels.lastSeen, .string(relative(host?.seenTime))),
+ (Info.Keys.lastInventory, Info.Labels.lastInventory,
+ .string(isRefetching ? Constants.Actions.refetching : relative(host?.detailUpdatedAt))),
+ (Info.Keys.url, Info.Labels.url, .string(FleetDeviceIdentity.serverURL()?.host() ?? unknown)),
+ ]
+ }
+
+ /// Called with policies that started failing since the last check, to notify the user once per failure.
+ @ObservationIgnored var onNewlyFailing: (([FleetPolicy]) -> Void)?
+ /// Called when a refetch finishes, so other Fleet data can be refreshed too.
+ @ObservationIgnored var onRefetchFinished: (() -> Void)?
+ /// Called when Fleet starts asking for a sign-in, at most once a day while the user stays out.
+ @ObservationIgnored var onSignInRequired: (() -> Void)?
+
+ @ObservationIgnored private let client: any FleetDeviceAPI
+ @ObservationIgnored private let defaults: UserDefaults
+ @ObservationIgnored private let refetchPollInterval: TimeInterval
+ @ObservationIgnored private var refreshTask: Task?
+ @ObservationIgnored private var monitorTask: Task?
+
+ /// Ids of failing policies the user has been told about.
+ static let reportedFailuresKey = "FleetReportedFailingPolicies"
+ /// When the user was last told to sign in, so an hourly poll doesn't nag hourly.
+ static let signInNotifiedKey = "FleetSignInNotifiedAt"
+ /// How long to wait before mentioning a sign-in again while the user stays signed out.
+ private static let signInReminderInterval: TimeInterval = 24 * 3600
+ /// Fleet re-runs details and policies at the Mac's next check-in, usually within a minute or two.
+ private static let refetchTimeout: TimeInterval = 5 * 60
+
+ init(client: any FleetDeviceAPI = FleetClient.shared, defaults: UserDefaults = .standard, refetchPollInterval: TimeInterval = 15) {
+ self.client = client
+ self.defaults = defaults
+ self.refetchPollInterval = refetchPollInterval
+ }
+
+ /// Failing policies, critical ones first.
+ var failingPolicies: [FleetPolicy] {
+ policies.filter(\.isFailing).sorted {
+ if ($0.critical ?? false) != ($1.critical ?? false) { return $0.critical ?? false }
+ return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
+ }
+ }
+
+ var passingPolicies: [FleetPolicy] {
+ policies.filter { $0.response == "pass" }
+ .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
+ }
+
+ /// Policies that have run on this Mac.
+ var checkedPolicies: [FleetPolicy] {
+ policies.filter { $0.response == "pass" || $0.response == "fail" }
+ }
+
+ func refresh() async {
+ if let refreshTask {
+ await refreshTask.value
+ return
+ }
+ let task = Task { await performRefresh() }
+ refreshTask = task
+ await task.value
+ refreshTask = nil
+ }
+
+ func refreshIfStale(maxAge: TimeInterval = 5 * 60) async {
+ if let lastUpdated, Date().timeIntervalSince(lastUpdated) < maxAge, loadState == .loaded {
+ return
+ }
+ await refresh()
+ }
+
+ /// Checks periodically, for notifications while the app runs in the background.
+ func startMonitoring(interval: TimeInterval = 3600) {
+ stopMonitoring()
+ monitorTask = Task { [weak self] in
+ while !Task.isCancelled {
+ await self?.refresh()
+ try? await Task.sleep(for: .seconds(interval))
+ }
+ }
+ }
+
+ func stopMonitoring() {
+ monitorTask?.cancel()
+ monitorTask = nil
+ }
+
+ /// Asks Fleet to re-read this Mac's details and re-run its policies, then waits for the results.
+ /// Throws if Fleet didn't accept the request; the wait continues in the background.
+ func refetch() async throws {
+ guard !isRefetching else { return }
+ // Set before the request, so a second tap while it's in flight doesn't start another re-check
+ isRefetching = true
+ refetchError = nil
+ do {
+ try await client.refetch()
+ } catch {
+ Logger.shared.logError("Fleet: re-check request failed: \(error.localizedDescription)")
+ refetchError = Constants.FleetInfo.refetchFailed
+ isRefetching = false
+ throw error
+ }
+ Task { await waitForRefetch() }
+ }
+
+ private func waitForRefetch() async {
+ let deadline = Date().addingTimeInterval(Self.refetchTimeout)
+ while Date() < deadline {
+ try? await Task.sleep(for: .seconds(refetchPollInterval))
+ await refresh()
+ // Stop on errors rather than keep sending requests Fleet refuses
+ guard loadState == .loaded else { break }
+ if host?.refetchRequested != true { break }
+ }
+ isRefetching = false
+ onRefetchFinished?()
+ }
+
+ private func performRefresh() async {
+ if let problem = client.configurationProblem {
+ loadState = .failed(problem)
+ return
+ }
+ // Fetched first and on its own: it's outside the SSO gate, so it still answers when the
+ // host record below doesn't, and it's what the signed-out cards fall back to.
+ do {
+ summary = try await client.desktopSummary()
+ } catch {
+ Logger.shared.logDebug("Fleet: couldn't read the desktop summary: \(error.localizedDescription)")
+ }
+
+ do {
+ host = try await client.deviceHost()
+ lastUpdated = Date()
+ loadState = .loaded
+ // Signed in again, so a later sign-out is reported promptly rather than waiting out
+ // the remainder of the reminder interval
+ defaults.removeObject(forKey: Self.signInNotifiedKey)
+ reportNewlyFailing()
+ } catch FleetError.ssoRequired {
+ loadState = .ssoRequired
+ reportSignInRequired()
+ } catch {
+ // Keep showing the last details
+ loadState = .failed(error.localizedDescription)
+ }
+ }
+
+ /// Tells the app a sign-in is needed, but no more than once per reminder interval: this runs on
+ /// every poll, and the session stays expired until the user does something about it.
+ private func reportSignInRequired() {
+ if let last = defaults.object(forKey: Self.signInNotifiedKey) as? Date,
+ Date().timeIntervalSince(last) < Self.signInReminderInterval {
+ return
+ }
+ defaults.set(Date(), forKey: Self.signInNotifiedKey)
+ onSignInRequired?()
+ }
+
+ private func reportNewlyFailing() {
+ let reported = Set(defaults.array(forKey: Self.reportedFailuresKey) as? [Int] ?? [])
+ let failing = failingPolicies
+ let newlyFailing = failing.filter { !reported.contains($0.id) }
+
+ // Forget policies once they pass, so they're reported again if they fail later. Policies that
+ // haven't run again yet keep their state.
+ let passing = Set(policies.filter { $0.response == "pass" }.map(\.id))
+ let knownIDs = Set(policies.map(\.id))
+ let stillReported = reported.filter { knownIDs.contains($0) && !passing.contains($0) }
+ .union(failing.map(\.id))
+ if stillReported != reported {
+ defaults.set(stillReported.sorted(), forKey: Self.reportedFailuresKey)
+ }
+
+ if !newlyFailing.isEmpty {
+ Logger.shared.logDebug("Fleet: \(newlyFailing.count) policies started failing")
+ onNewlyFailing?(newlyFailing)
+ }
+ }
+}
diff --git a/SupportCompanion/ViewModels/FleetSoftwareManager.swift b/SupportCompanion/ViewModels/FleetSoftwareManager.swift
new file mode 100644
index 0000000..3582f2e
--- /dev/null
+++ b/SupportCompanion/ViewModels/FleetSoftwareManager.swift
@@ -0,0 +1,378 @@
+//
+// FleetSoftwareManager.swift
+// SupportCompanion
+//
+// Self-service software catalog for Fleet mode.
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+final class FleetSoftwareManager {
+ enum LoadState: Equatable {
+ case idle
+ case loading
+ case loaded
+ /// Fleet Desktop SSO is enabled and the user hasn't signed in.
+ case ssoRequired
+ /// No Fleet server or device token on this Mac.
+ case notConfigured
+ case failed(String)
+ }
+
+ private(set) var titles: [FleetSoftwareTitle] = []
+ private(set) var categories: [FleetSoftwareCategory] = []
+ private(set) var loadState: LoadState = .idle
+ private(set) var lastUpdated: Date?
+ /// Why Fleet isn't set up, shown with the `.notConfigured` state.
+ private(set) var configurationProblem: String?
+ /// Actions started from this app that haven't finished yet, by title id.
+ private(set) var runningActions: [Int: FleetSoftwareTitle.Action] = [:]
+ /// Why the last action for a title couldn't be started, by title id.
+ private(set) var actionErrors: [Int: String] = [:]
+ /// Versions Fleet reported installed before its inventory shows them, by title id. Fleet's installed
+ /// versions come from inventory, which lags the install result by a minute or more.
+ private(set) var installedVersionsAwaitingInventory: [Int: String] = [:]
+ /// Failed custom package installs whose output said the app has to be closed, by install UUID.
+ private(set) var appOpenInstallUUIDs: Set = []
+
+ enum ActionOutcome: Equatable {
+ case succeeded
+ case failed
+ /// The install didn't run because the app was open.
+ case appOpen
+ }
+
+ /// Called when an action started from this app finishes.
+ @ObservationIgnored var onActionFinished: ((FleetSoftwareTitle, FleetSoftwareTitle.Action, ActionOutcome) -> Void)?
+ /// Called after the catalog is fetched.
+ @ObservationIgnored var onCatalogUpdated: (() -> Void)?
+
+ @ObservationIgnored private let client: any FleetSoftwareAPI
+ @ObservationIgnored private var refreshTask: Task?
+ @ObservationIgnored private var monitorTask: Task?
+ @ObservationIgnored private var categoriesFetchedAt: Date?
+ @ObservationIgnored private var categoriesUnsupported = false
+ @ObservationIgnored private var pollTask: Task?
+ @ObservationIgnored private var actionStartedAt: [Int: Date] = [:]
+ /// Running actions Fleet has reported as pending, so a title's status from before the action isn't taken as its result.
+ @ObservationIgnored private var actionsSeenPending: Set = []
+ @ObservationIgnored private var polledPendingIDs: Set = []
+ /// Install UUIDs whose output has been checked, so each result is fetched once.
+ @ObservationIgnored private var checkedInstallUUIDs: Set = []
+
+ /// Categories rarely change, so they're fetched at most this often.
+ private static let categoriesRefreshInterval: TimeInterval = 10 * 60
+ private let pendingPollInterval: TimeInterval
+ /// Longest time to poll for pending installs; Fleet keeps them pending until the Mac checks in.
+ private static let pendingPollLimit: TimeInterval = 30 * 60
+ /// How long an action may go without Fleet reporting it pending before its current status is taken as the result.
+ private static let pendingGracePeriod: TimeInterval = 2 * 60
+
+ init(client: any FleetSoftwareAPI = FleetClient.shared, pendingPollInterval: TimeInterval = 10) {
+ self.client = client
+ self.pendingPollInterval = pendingPollInterval
+ }
+
+ var updatesAvailable: [FleetSoftwareTitle] {
+ titles.filter(hasUpdate)
+ }
+
+ /// Whether a newer version is available and hasn't just been installed.
+ func hasUpdate(_ title: FleetSoftwareTitle) -> Bool {
+ title.isUpdateAvailable && installedVersionsAwaitingInventory[title.id] != title.availableVersion
+ }
+
+ /// Whether an install or uninstall is queued or running for the title.
+ func isBusy(_ title: FleetSoftwareTitle) -> Bool {
+ runningActions[title.id] != nil || title.isPending
+ }
+
+ // MARK: - Actions
+
+ /// Asks Fleet to install, update or uninstall a title, then follows it until Fleet reports the result.
+ func perform(_ action: FleetSoftwareTitle.Action, on title: FleetSoftwareTitle) async {
+ guard !isBusy(title) else { return }
+ runningActions[title.id] = action
+ actionStartedAt[title.id] = Date()
+ actionErrors[title.id] = nil
+
+ do {
+ switch action {
+ case .install, .update, .reinstall:
+ try await client.install(titleID: title.id)
+ case .uninstall:
+ try await client.uninstall(titleID: title.id)
+ }
+ Logger.shared.logDebug("Fleet: requested \(action) of \(title.title)")
+ } catch {
+ Logger.shared.logError("Fleet: couldn't request \(action) of \(title.title): \(error.localizedDescription)")
+ clearAction(title.id)
+ actionErrors[title.id] = error.localizedDescription
+ if case FleetError.ssoRequired = error {
+ loadState = .ssoRequired
+ }
+ return
+ }
+
+ await refresh()
+ startPollingWhilePending()
+ }
+
+ /// Whether the title's last install didn't run because its app was open.
+ func isWaitingForAppToClose(_ title: FleetSoftwareTitle) -> Bool {
+ guard title.status == .failedInstall else { return false }
+ if title.skippedInstall == true { return true }
+ return title.installer?.lastInstall?.installUuid.map(appOpenInstallUUIDs.contains) ?? false
+ }
+
+ /// The action that retries an install that failed or waited for the app to close.
+ func retryAction(for title: FleetSoftwareTitle) -> FleetSoftwareTitle.Action {
+ if !title.isInstalled { return .install }
+ return hasUpdate(title) ? .update : .reinstall
+ }
+
+ /// Quits the title's app and retries its install. Leaves an error on the title if the app didn't quit.
+ func quitAndRetry(_ title: FleetSoftwareTitle) async {
+ guard await FleetRunningApps.shared.quit(title) else {
+ actionErrors[title.id] = String(format: Constants.Fleet.couldNotQuit, title.title)
+ return
+ }
+ await perform(retryAction(for: title), on: title)
+ }
+
+ func quitAndRetry(titleID: Int) async {
+ await refreshIfStale()
+ guard let title = titles.first(where: { $0.id == titleID }) else { return }
+ await quitAndRetry(title)
+ }
+
+ /// Updates every title with an update available, e.g. from an update notification's button.
+ func updateAll() async {
+ await refreshIfStale()
+ for title in updatesAvailable where !isBusy(title) {
+ await perform(.update, on: title)
+ }
+ }
+
+ /// The output of a title's last failed install or uninstall.
+ func failureOutput(for title: FleetSoftwareTitle) async throws -> String {
+ if title.status == .failedUninstall {
+ guard let executionID = title.softwarePackage?.lastUninstall?.scriptExecutionId else { return "" }
+ let result = try await client.uninstallResult(executionID: executionID)
+ return Self.joinOutput([result.output, result.message])
+ }
+ guard let installUUID = title.installer?.lastInstall?.installUuid,
+ let result = try await client.installResult(installUUID: installUUID) else { return "" }
+ return Self.joinOutput([result.preInstallQueryOutput, result.output, result.postInstallScriptOutput])
+ }
+
+ private static func joinOutput(_ parts: [String?]) -> String {
+ parts.compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+ .joined(separator: "\n\n")
+ }
+
+ private func clearAction(_ id: Int) {
+ runningActions[id] = nil
+ actionStartedAt[id] = nil
+ actionsSeenPending.remove(id)
+ }
+
+ /// Refreshes often while anything is pending, so progress shows without waiting for the regular refresh.
+ private func startPollingWhilePending() {
+ guard pollTask == nil else { return }
+ pollTask = Task { [weak self, pendingPollInterval] in
+ let deadline = Date().addingTimeInterval(Self.pendingPollLimit)
+ while !Task.isCancelled, Date() < deadline {
+ try? await Task.sleep(for: .seconds(pendingPollInterval))
+ guard let self else { return }
+ await self.refresh()
+ if !self.titles.contains(where: \.isPending) && self.runningActions.isEmpty {
+ break
+ }
+ // Don't keep polling a server that's refusing requests
+ if self.loadState != .loaded {
+ break
+ }
+ }
+ self?.pollTask = nil
+ }
+ }
+
+ /// Checks failed custom package installs for output saying the app has to be closed first.
+ /// Fleet flags this itself for Fleet-maintained apps, so those aren't fetched.
+ private func detectAppOpenInstalls() async {
+ let messages = FleetAppOpenMessages.current
+ for title in titles where title.status == .failedInstall && title.skippedInstall != true {
+ guard let uuid = title.installer?.lastInstall?.installUuid, !checkedInstallUUIDs.contains(uuid) else { continue }
+ let fetched: FleetInstallResult?
+ do {
+ fetched = try await client.installResult(installUUID: uuid)
+ } catch FleetError.server(status: 404, _), FleetError.decoding(_) {
+ // The result no longer exists or can't be read, so asking again won't help
+ checkedInstallUUIDs.insert(uuid)
+ continue
+ } catch {
+ // Network, server or sign-in problems: stop here and check again on the next refresh
+ return
+ }
+ checkedInstallUUIDs.insert(uuid)
+ guard let result = fetched else { continue }
+ let output = [result.preInstallQueryOutput, result.output, result.postInstallScriptOutput]
+ .compactMap { $0 }.joined(separator: "\n")
+ if messages.matches(output) {
+ appOpenInstallUUIDs.insert(uuid)
+ }
+ }
+ }
+
+ /// Whether the title's failed install output hasn't been checked for an open app yet.
+ private func needsOutputCheck(_ title: FleetSoftwareTitle) -> Bool {
+ guard title.status == .failedInstall, title.skippedInstall != true,
+ let uuid = title.installer?.lastInstall?.installUuid else { return false }
+ return !checkedInstallUUIDs.contains(uuid)
+ }
+
+ /// Reports actions started from this app that Fleet has finished.
+ private func trackRunningActions() {
+ for (id, action) in runningActions {
+ guard let title = titles.first(where: { $0.id == id }) else {
+ clearAction(id)
+ continue
+ }
+ if title.isPending {
+ actionsSeenPending.insert(id)
+ continue
+ }
+ let startedAt = actionStartedAt[id] ?? .distantPast
+ guard actionsSeenPending.contains(id) || Date().timeIntervalSince(startedAt) > Self.pendingGracePeriod else {
+ continue
+ }
+ // Wait for the install output to be checked, so an install blocked by an open app isn't reported as a
+ // plain failure; give up after the polling limit
+ if needsOutputCheck(title), Date().timeIntervalSince(startedAt) < Self.pendingPollLimit {
+ continue
+ }
+ clearAction(id)
+ let succeeded = !title.hasFailed
+ let outcome: ActionOutcome = succeeded ? .succeeded : (isWaitingForAppToClose(title) ? .appOpen : .failed)
+ Logger.shared.logDebug("Fleet: \(action) of \(title.title) finished: \(outcome)")
+ if succeeded && action != .uninstall, let version = title.availableVersion {
+ installedVersionsAwaitingInventory[id] = version
+ }
+ onActionFinished?(title, action, outcome)
+ if succeeded && action != .uninstall {
+ // Installed versions come from inventory, which Fleet otherwise updates about hourly
+ Task { try? await client.refetch() }
+ }
+ }
+ }
+
+ /// Refreshes the catalog. Concurrent calls share one request.
+ func refresh() async {
+ if let refreshTask {
+ await refreshTask.value
+ return
+ }
+ let task = Task { await performRefresh() }
+ refreshTask = task
+ await task.value
+ refreshTask = nil
+ }
+
+ /// Refreshes unless the catalog was fetched within `maxAge`, so several views asking for data share requests.
+ func refreshIfStale(maxAge: TimeInterval = 30) async {
+ if let lastUpdated, Date().timeIntervalSince(lastUpdated) < maxAge, loadState == .loaded {
+ return
+ }
+ await refresh()
+ }
+
+ /// Refreshes periodically while the catalog is on screen. Requests still go through FleetClient's backoff.
+ func startMonitoring(interval: TimeInterval = 60) {
+ stopMonitoring()
+ monitorTask = Task { [weak self] in
+ while !Task.isCancelled {
+ await self?.refresh()
+ try? await Task.sleep(for: .seconds(interval))
+ }
+ }
+ }
+
+ func stopMonitoring() {
+ monitorTask?.cancel()
+ monitorTask = nil
+ }
+
+ private func performRefresh() async {
+ if let problem = client.configurationProblem {
+ if problem != configurationProblem {
+ Logger.shared.logError("Fleet: \(problem)")
+ }
+ configurationProblem = problem
+ loadState = .notConfigured
+ return
+ }
+ configurationProblem = nil
+ if titles.isEmpty {
+ loadState = .loading
+ }
+
+ do {
+ let fetched = try await client.selfServiceSoftware()
+ titles = fetched.sorted { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }
+ lastUpdated = Date()
+ loadState = .loaded
+ // Forget installed versions once inventory has caught up, or Fleet offers a different version
+ let awaiting = installedVersionsAwaitingInventory.filter { id, version in
+ titles.first { $0.id == id }.map { $0.isUpdateAvailable && $0.availableVersion == version } ?? false
+ }
+ if awaiting != installedVersionsAwaitingInventory {
+ installedVersionsAwaitingInventory = awaiting
+ }
+ onCatalogUpdated?()
+ await detectAppOpenInstalls()
+ trackRunningActions()
+ // Also follow installs queued elsewhere (Fleet's web page, an admin), but only once per install,
+ // so a title stuck pending doesn't keep this polling after the limit
+ let pendingIDs = Set(titles.filter(\.isPending).map(\.id))
+ if !pendingIDs.isSubset(of: polledPendingIDs) {
+ polledPendingIDs.formUnion(pendingIDs)
+ startPollingWhilePending()
+ }
+ } catch let error as FleetError {
+ switch error {
+ case .ssoRequired:
+ loadState = .ssoRequired
+ case .notConfigured:
+ loadState = .notConfigured
+ default:
+ // Keep showing the last catalog; the view shows the error alongside it
+ loadState = .failed(error.localizedDescription)
+ }
+ return
+ } catch {
+ loadState = .failed(error.localizedDescription)
+ return
+ }
+
+ guard !categoriesUnsupported,
+ categoriesFetchedAt.map({ Date().timeIntervalSince($0) > Self.categoriesRefreshInterval }) ?? true else {
+ return
+ }
+ do {
+ categories = try await client.selfServiceCategories()
+ categoriesFetchedAt = Date()
+ } catch FleetError.server(status: 404, _) {
+ // Older Fleet servers don't have categories, so don't keep asking
+ Logger.shared.logDebug("Fleet: server doesn't support self-service categories")
+ categoriesUnsupported = true
+ } catch {
+ // Categories are optional; the catalog is still usable without them
+ categoriesFetchedAt = Date()
+ }
+ }
+}
diff --git a/SupportCompanion/ViewModels/JamfInfoManager.swift b/SupportCompanion/ViewModels/JamfInfoManager.swift
new file mode 100644
index 0000000..1ee1795
--- /dev/null
+++ b/SupportCompanion/ViewModels/JamfInfoManager.swift
@@ -0,0 +1,112 @@
+//
+// JamfInfoManager.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-11-12.
+//
+
+import Foundation
+import Observation
+
+@MainActor
+@Observable
+class JamfInfoManager {
+ @ObservationIgnored private var monitorTask: Task?
+ private let appStateManager: AppStateManager
+
+ var jamfInfo: JamfInfo
+
+ init(jamfInfo: JamfInfo, appStateManager: AppStateManager) {
+ self.jamfInfo = jamfInfo
+ self.appStateManager = appStateManager
+ }
+
+ func refresh() {
+ updateJamfInfo()
+ }
+
+ func updateJamfInfo() {
+ Task {
+ let lastCheckIn: String
+ let lastInventory: String
+ let url: String
+
+ do {
+ lastCheckIn = try await getLastCheckIn()
+ } catch {
+ Logger.shared.logError("Failed to fetch last check-in: \(error.localizedDescription)")
+ lastCheckIn = "Unknown"
+ }
+
+ do {
+ lastInventory = try await getLastInventoryUpdate()
+ } catch {
+ Logger.shared.logError("Failed to fetch last inventory update: \(error.localizedDescription)")
+ lastInventory = "Unknown"
+ }
+
+ do {
+ url = try await getJamfUrl()
+ } catch {
+ Logger.shared.logError("Failed to fetch Jamf URL: \(error.localizedDescription)")
+ url = "Unknown"
+ }
+
+ self.jamfInfo = JamfInfo(
+ lastCheckIn: lastCheckIn,
+ lastInventory: lastInventory,
+ url: url,
+ jamfID: appStateManager.jamfId
+ )
+ }
+ }
+
+ func startMonitoring(interval: TimeInterval = 300) {
+ stopMonitoring() // Stop any existing task to avoid duplicates
+ Logger.shared.logDebug("Starting jamf info monitoring")
+ monitorTask = Task {
+ while !Task.isCancelled {
+ let lastCheckIn: String
+ let lastInventory: String
+ let url: String
+
+ do {
+ lastCheckIn = try await getLastCheckIn()
+ } catch {
+ Logger.shared.logError("Failed to fetch last check-in: \(error.localizedDescription)")
+ lastCheckIn = "Unknown"
+ }
+
+ do {
+ lastInventory = try await getLastInventoryUpdate()
+ } catch {
+ Logger.shared.logError("Failed to fetch last inventory update: \(error.localizedDescription)")
+ lastInventory = "Unknown"
+ }
+
+ do {
+ url = try await getJamfUrl()
+ } catch {
+ Logger.shared.logError("Failed to fetch Jamf URL: \(error.localizedDescription)")
+ url = "Unknown"
+ }
+
+ self.jamfInfo = JamfInfo(
+ lastCheckIn: lastCheckIn,
+ lastInventory: lastInventory,
+ url: url,
+ jamfID: appStateManager.jamfId
+ )
+
+ try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
+ }
+ }
+ }
+
+ /// Stops the periodic monitoring task.
+ func stopMonitoring() {
+ Logger.shared.logDebug("Stopping jamf info monitoring")
+ monitorTask?.cancel()
+ monitorTask = nil
+ }
+}
diff --git a/SupportCompanion/ViewModels/JsonCardManager.swift b/SupportCompanion/ViewModels/JsonCardManager.swift
index 29bcb1f..91f43b4 100644
--- a/SupportCompanion/ViewModels/JsonCardManager.swift
+++ b/SupportCompanion/ViewModels/JsonCardManager.swift
@@ -6,36 +6,49 @@
//
import Foundation
+import Observation
-class JsonCardManager: ObservableObject {
+@MainActor
+@Observable
+class JsonCardManager {
private var appState: AppStateManager
- private var fileWatcher: FileWatcher?
+ @ObservationIgnored private var fileWatcher: FileWatcher?
init(appState: AppStateManager) {
self.appState = appState
}
func watchFile(_ filePath: String) {
- fileWatcher = FileWatcher(filePath: filePath) { [weak self] in
- self?.loadFromFile(filePath)
+ let expanded = (filePath as NSString).expandingTildeInPath
+ let resolved = URL(fileURLWithPath: expanded).resolvingSymlinksInPath().path
+ fileWatcher = nil
+ Logger.shared.logDebug("JsonCardManager: watching file at \(resolved)")
+ fileWatcher = FileWatcher(filePath: resolved) { [weak self] in
+ Task { @MainActor [weak self] in
+ Logger.shared.logDebug("JsonCardManager: file change detected, reloading")
+ self?.loadFromFile(resolved)
+ }
}
}
func loadFromFile(_ fileName: String) {
- let fileURL = URL(fileURLWithPath: fileName)
+ let expanded = (fileName as NSString).expandingTildeInPath
+ let fileURL = URL(fileURLWithPath: expanded).resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: fileURL.path) else {
- Logger.shared.logDebug("Csutom Card File not found: \(fileURL.path)")
+ Logger.shared.logDebug("Custom Card File not found: \(fileURL.path)")
return
}
do {
let data = try Data(contentsOf: fileURL)
let decodedCards = try JSONDecoder().decode([JsonCard].self, from: data)
- DispatchQueue.main.async {
- self.appState.JsonCards = decodedCards
- }
+ appState.JsonCards = decodedCards
} catch {
Logger.shared.logError("Failed to load cards: \(error.localizedDescription)")
}
}
+
+ func stopWatching() {
+ fileWatcher = nil
+ }
}
diff --git a/SupportCompanion/ViewModels/MDMInfoManager.swift b/SupportCompanion/ViewModels/MDMInfoManager.swift
index 04e64f0..0d914a9 100644
--- a/SupportCompanion/ViewModels/MDMInfoManager.swift
+++ b/SupportCompanion/ViewModels/MDMInfoManager.swift
@@ -6,8 +6,11 @@
//
import Foundation
+import Observation
-class MdmInfoManager: ObservableObject {
+@MainActor
+@Observable
+class MdmInfoManager {
static let shared = MdmInfoManager(
mdmInfo: MdmInfo(
id: UUID(),
@@ -17,7 +20,7 @@ class MdmInfoManager: ObservableObject {
)
)
- @Published var mdmInfo: MdmInfo
+ var mdmInfo: MdmInfo
init(mdmInfo: MdmInfo) {
self.mdmInfo = mdmInfo
@@ -29,15 +32,13 @@ class MdmInfoManager: ObservableObject {
func updateMdmInfo() {
Task {
- let mdmDetails = await getMDMStatus()
- DispatchQueue.main.async {
- self.mdmInfo = MdmInfo(
- id: UUID(),
- abm: mdmDetails["ABM"] ?? "",
- enrolled: mdmDetails["Enrolled"] ?? "",
- enrolledDate: mdmDetails["EnrollmentDate"] ?? ""
- )
- }
+ let mdmDetails = await getMDMStatus()
+ self.mdmInfo = MdmInfo(
+ id: UUID(),
+ abm: mdmDetails["ABM"] ?? "",
+ enrolled: mdmDetails["Enrolled"] ?? "",
+ enrolledDate: mdmDetails["EnrollmentDate"] ?? ""
+ )
}
}
}
diff --git a/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift b/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift
new file mode 100644
index 0000000..f287b03
--- /dev/null
+++ b/SupportCompanion/ViewModels/PendingFleetUpdatesManager.swift
@@ -0,0 +1,78 @@
+//
+// PendingFleetUpdatesManager.swift
+// SupportCompanion
+//
+// Home cards and update notifications for Fleet mode, from FleetSoftwareManager's catalog.
+//
+
+import Foundation
+
+class PendingFleetUpdatesManager: PendingUpdatesManager {
+ private var software: FleetSoftwareManager { appState.fleetSoftwareManager }
+
+ // MARK: - Install Percentage
+
+ override func getInstallPercentage() async {
+ await software.refreshIfStale()
+ publishCounts()
+ }
+
+ // MARK: - Presentation
+
+ override var pendingUpdates: [any PendingUpdate] {
+ software.updatesAvailable.compactMap(PendingFleetUpdate.init(title:))
+ }
+
+ override func managementApp(forUpdates: Bool) -> (name: String, path: String) {
+ (Constants.Navigation.apps, "supportcompanion://apps")
+ }
+
+ override func catalogSuggestion(for facts: InstallerFacts) -> CatalogSuggestion? {
+ suggestion(
+ matching: facts,
+ in: software.titles.map { CatalogEntry(name: $0.title, bundleIdentifier: $0.bundleIdentifier) }
+ )
+ }
+
+ /// The apps page is part of this app, so "Open Self Service" would be misleading.
+ override func openManagementAppTitle(forUpdates: Bool) -> String {
+ forUpdates ? Constants.Fleet.viewUpdates : Constants.Fleet.viewApps
+ }
+
+ // MARK: - Pending Updates
+
+ override func fetchPendingUpdatesList() async {
+ await software.refreshIfStale()
+ publishCounts()
+ }
+
+ override func fetchPendingUpdates() async {
+ await software.refreshIfStale()
+ publishCounts()
+
+ let updates = software.updatesAvailable.compactMap(PendingFleetUpdate.init(title:))
+ guard !updates.isEmpty, appState.preferences.fleetNotifyUpdates, !appState.preferences.hiddenCards.contains(Constants.Cards.pendingAppUpdates) else { return }
+ let list = updates.map { "\($0.name) \($0.availableVersion)" }.joined(separator: ", ")
+ // Clicking the notification shows the updates; its button installs them
+ NotificationService(appState: appState).sendNotification(
+ message: "\(appState.preferences.notifications.appUpdateNotificationMessage)\n\(list)",
+ buttonText: appState.preferences.notifications.appUpdateNotificationButtonText,
+ command: NotificationService.fleetUpdateAllCommand,
+ openURL: "supportcompanion://apps",
+ notificationType: .appUpdate
+ )
+ }
+
+ /// Updates the counts behind the patch progress cards and the Apps badge.
+ func publishCounts() {
+ guard software.loadState == .loaded || !software.titles.isEmpty else { return }
+ let pending = software.updatesAvailable.count
+ let upToDate = software.titles.filter { $0.isInstalled && !software.hasUpdate($0) }.count
+ let total = pending + upToDate
+ let percentage = total > 0 ? Double(upToDate) / Double(total) * 100 : 0
+
+ if appState.pendingUpdatesCount != pending { appState.pendingUpdatesCount = pending }
+ if appState.installedAppsCount != upToDate { appState.installedAppsCount = upToDate }
+ if appState.installPercentage != percentage { appState.installPercentage = percentage }
+ }
+}
diff --git a/SupportCompanion/ViewModels/PendingIntuneUpdatesManager.swift b/SupportCompanion/ViewModels/PendingIntuneUpdatesManager.swift
index f3eb6cf..2832f61 100644
--- a/SupportCompanion/ViewModels/PendingIntuneUpdatesManager.swift
+++ b/SupportCompanion/ViewModels/PendingIntuneUpdatesManager.swift
@@ -8,161 +8,65 @@
import Foundation
import Combine
-class PendingIntuneUpdatesManager {
- private var appState: AppStateManager
- private var updateCheckTimer: Timer?
- private var fetchListTimer: Timer?
- private var installPercentageTask: Task?
- private var isInstallPercentageTaskRunning = false
+class PendingIntuneUpdatesManager: PendingUpdatesManager {
private var intuneApps = IntuneApps()
- init(appState: AppStateManager) {
- self.appState = appState
- //self.intuneApps = IntuneApps(appState: appState)
- }
+ // MARK: - Install Percentage
- func getIntuneInstallPercentage() async {
+ override func getInstallPercentage() async {
Logger.shared.logDebug("Getting Intune install percentage")
- do {
- // Fetch counts concurrently
- async let installedCount = intuneApps.getInstalledAppsCountFromLog()
- async let pendingCount = intuneApps.getPendingUpdatesCountFromLog()
- let (installed, pending) = await (installedCount, pendingCount)
-
- let totalApps = installed + pending
- let newInstallPercentage = totalApps > 0
- ? (Double(installed) / Double(totalApps)) * 100
- : 0.0
-
- // Update only if the percentage has changed
- if newInstallPercentage != appState.installPercentage {
- DispatchQueue.main.async {
- self.appState.installedAppsCount = installed
- self.appState.pendingUpdatesCount = pending
- self.appState.installPercentage = newInstallPercentage
- Logger.shared.logDebug("Install percentage updated: \(self.appState.installPercentage)%")
- }
- } else {
- Logger.shared.logDebug("Install percentage unchanged: \(self.appState.installPercentage)%")
- }
+ async let installedCount = intuneApps.getInstalledAppsCountFromLog()
+ async let pendingCount = intuneApps.getPendingUpdatesCountFromLog()
+ let (installed, pending) = await (installedCount, pendingCount)
+
+ let totalApps = installed + pending
+ let newInstallPercentage = totalApps > 0
+ ? (Double(installed) / Double(totalApps)) * 100
+ : 0.0
+
+ if newInstallPercentage != appState.installPercentage {
+ appState.installedAppsCount = installed
+ appState.pendingUpdatesCount = pending
+ appState.installPercentage = newInstallPercentage
+ Logger.shared.logDebug("Install percentage updated: \(appState.installPercentage)%")
+ } else {
+ Logger.shared.logDebug("Install percentage unchanged: \(appState.installPercentage)%")
}
}
-
- func startInstallPercentageTask() {
- guard !isInstallPercentageTaskRunning else {
- Logger.shared.logDebug("Install percentage task already running")
- return
- }
- isInstallPercentageTaskRunning = true
- Logger.shared.logDebug("Starting install percentage task")
+ // MARK: - Presentation
- installPercentageTask = Task {
- await self.getIntuneInstallPercentage()
- isInstallPercentageTaskRunning = false
- }
- }
+ override var pendingUpdates: [any PendingUpdate] { appState.pendingIntuneUpdates }
- func stopInstallPercentageTask() {
- Logger.shared.logDebug("Stopping install percentage task")
- installPercentageTask?.cancel()
- installPercentageTask = nil
- isInstallPercentageTaskRunning = false
- }
-
- func getPendingIntuneUpdates() async {
- Logger.shared.logDebug("Getting Intune pending updates")
-
- do {
- let pendingUpdates = await intuneApps.getPendingUpdatesListFromLog()
- DispatchQueue.main.async {
- self.appState.pendingIntuneUpdates = pendingUpdates
- }
- }
- }
-
- func fetchPendingUpdates() async {
- do {
- let updates = await intuneApps.getPendingUpdatesCountFromLog()
- DispatchQueue.main.async {
- if self.appState.pendingUpdatesCount != updates {
- self.appState.pendingUpdatesCount = updates
- }
- }
- if updates > 0 && !appState.preferences.hiddenCards.contains("PendingAppUpdates") {
- NotificationService(appState: appState).sendNotification(
- message: appState.preferences.appUpdateNotificationMessage,
- buttonText: appState.preferences.appUpdateNotificationButtonText,
- command: appState.preferences.appUpdateNotificationCommand,
- notificationType: .appUpdate
- )
- }
+ override var pendingUpdatesDetailColumnTitle: String? { "" }
- DispatchQueue.main.async {
- self.appState.pendingUpdatesCount = updates
- }
- }
+ override func managementApp(forUpdates: Bool) -> (name: String, path: String) {
+ ("Company Portal", Constants.AppPaths.companyPortal)
}
-
- func startUpdateCheckTimer() {
- Logger.shared.logDebug("Starting app update check timer")
- // Run the task immediately
- Task {
- await fetchPendingUpdates()
- }
+ // MARK: - Pending Updates
- // Set up the timer to run every hour
- updateCheckTimer?.invalidate() // Stop any existing timer
- updateCheckTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
- Task {
- await self?.fetchPendingUpdates()
- }
- }
- }
-
- func stopUpdateCheckTimer() {
- Logger.shared.logDebug("Stopping app update check timer")
- updateCheckTimer?.invalidate()
- updateCheckTimer = nil
- }
-
- func fetchPendingUpdatesList() async {
- do {
- let updates = await intuneApps.getPendingUpdatesListFromLog()
- DispatchQueue.main.async {
- guard updates != self.appState.pendingIntuneUpdates else {
- Logger.shared.logDebug("Pending updates list unchanged")
- return
- }
- self.appState.pendingIntuneUpdates = updates
- Logger.shared.logDebug("Updated pending updates list")
- }
+ override func fetchPendingUpdatesList() async {
+ let updates = await intuneApps.getPendingUpdatesListFromLog()
+ guard updates != appState.pendingIntuneUpdates else {
+ Logger.shared.logDebug("Pending updates list unchanged")
+ return
}
+ appState.pendingIntuneUpdates = updates
+ Logger.shared.logDebug("Updated pending updates list")
}
-
- func startFetchingList(interval: TimeInterval = 60) {
- Logger.shared.logDebug("Starting periodic fetch of pending updates list")
-
- // Run the task immediately
- Task {
- await fetchPendingUpdatesList()
- }
- // Start a periodic timer
- stopFetchingList()
- fetchListTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
- Logger.shared.logDebug("Timer triggered fetch pending updates list task")
- Task {
- await self?.fetchPendingUpdatesList()
- }
+ override func fetchPendingUpdates() async {
+ let updates = await intuneApps.getPendingUpdatesCountFromLog()
+ appState.pendingUpdatesCount = updates
+ if updates > 0 && !appState.preferences.hiddenCards.contains(Constants.Cards.pendingAppUpdates) {
+ NotificationService(appState: appState).sendNotification(
+ message: appState.preferences.notifications.appUpdateNotificationMessage,
+ buttonText: appState.preferences.notifications.appUpdateNotificationButtonText,
+ command: appState.preferences.notifications.appUpdateNotificationCommand,
+ notificationType: .appUpdate
+ )
}
}
-
- func stopFetchingList() {
- Logger.shared.logDebug("Stopping periodic fetch of pending updates list")
- fetchListTimer?.invalidate()
- fetchListTimer = nil
- }
}
diff --git a/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift
new file mode 100644
index 0000000..c4500e0
--- /dev/null
+++ b/SupportCompanion/ViewModels/PendingJamfUpdatesManager.swift
@@ -0,0 +1,275 @@
+//
+// PendingJamfUpdatesManager.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-10-13.
+//
+
+import Foundation
+import Combine
+
+class PendingJamfUpdatesManager: PendingUpdatesManager {
+
+ // MARK: - Install Percentage
+
+ override func getInstallPercentage() async {
+ Logger.shared.logDebug("Getting Jamf install percentage")
+ await refreshSelfService()
+ let parser = SSPlusParser()
+ guard await parser.parse() else { return }
+
+ let (allUpdates, updateCount, upToDateCount) = await computeUpdates(
+ policies: parser.policies,
+ patches: parser.patches,
+ now: Date()
+ )
+ // A patch installed from here is done even while the store still offers it, so move it across
+ // rather than leaving the ring showing work that is finished.
+ let installedButStillListed = updateCount - withoutRecentlyInstalled(allUpdates).count
+ let pendingCount = updateCount - installedButStillListed
+ let doneCount = upToDateCount + installedButStillListed
+ let totalApps = pendingCount + doneCount
+ let newInstallPercentage = totalApps > 0
+ ? (Double(doneCount) / Double(totalApps)) * 100
+ : 0.0
+
+ if newInstallPercentage != appState.installPercentage {
+ appState.installedAppsCount = doneCount
+ appState.pendingUpdatesCount = pendingCount
+ appState.installPercentage = newInstallPercentage
+ Logger.shared.logDebug("Install percentage updated: \(appState.installPercentage)%")
+ } else {
+ Logger.shared.logDebug("Install percentage unchanged: \(appState.installPercentage)%")
+ }
+ }
+
+ // MARK: - Presentation
+
+ override var pendingUpdates: [any PendingUpdate] { appState.pendingJamfUpdates }
+
+ // Text(String) isn't localized automatically, so resolve the "Due by" translation here
+ override var pendingUpdatesDetailColumnTitle: String? { String(localized: "Due by") }
+
+ override func managementApp(forUpdates: Bool) -> (name: String, path: String) {
+ ("Self Service", Constants.AppPaths.selfService)
+ }
+
+ // MARK: - Pending Updates
+
+ override func fetchPendingUpdates() async {
+ await getPendingJamfUpdates()
+ }
+
+ override func fetchPendingUpdatesList() async {
+ await getPendingJamfUpdates()
+ }
+
+ func getPendingJamfUpdates() async {
+ Logger.shared.logDebug("Getting Jamf pending updates")
+ await refreshSelfService()
+ let parser = SSPlusParser()
+ guard await parser.parse() else { return }
+
+ let (allUpdates, _, _) = await computeUpdates(
+ policies: parser.policies,
+ patches: parser.patches,
+ now: Date()
+ )
+ let pendingUpdates = withoutRecentlyInstalled(allUpdates)
+ appState.pendingJamfUpdates = pendingUpdates
+ if appState.pendingUpdatesCount != pendingUpdates.count {
+ appState.pendingUpdatesCount = pendingUpdates.count
+ }
+ if pendingUpdates.count > 0 && !appState.preferences.hiddenCards.contains(Constants.Cards.pendingAppUpdates) {
+ NotificationService(appState: appState).sendNotification(
+ message: appState.preferences.notifications.appUpdateNotificationMessage,
+ buttonText: appState.preferences.notifications.appUpdateNotificationButtonText,
+ command: appState.preferences.notifications.appUpdateNotificationCommand,
+ notificationType: .appUpdate
+ )
+ }
+ }
+
+ // MARK: - Self Service refresh
+
+ /// `pgrep`'s pattern is a regular expression, so the `+` has to be escaped for `-x` to match.
+ private static let selfServiceProcessPattern = "Self Service\\+"
+ private static let selfServiceProcessName = "Self Service+"
+
+ /// How long to wait for Self Service+ to rewrite its store before giving up on it.
+ private static let storeRefreshTimeout: TimeInterval = 20
+
+ func refreshSelfService() async {
+ guard appState.preferences.refreshSelfService else {
+ Logger.shared.logDebug("Self Service configured to not refresh itself")
+ return
+ }
+
+ // A running Self Service+ is left alone: it belongs to whoever opened it, and quitting a window
+ // somebody is reading to refresh a cache is not a trade worth making. The store then stays as
+ // Self Service+ last wrote it, which `RecentlyInstalledPatches` is there to cover.
+ if await isSelfServiceRunning() {
+ Logger.shared.logDebug("Self Service+ is running; leaving its store as it is for now")
+ return
+ }
+
+ let storePath = (Constants.Paths.jamfSelfServiceData as NSString).expandingTildeInPath
+ let before = storeModifiedDate(at: storePath)
+
+ _ = try? await ExecutionService.executeCommand(
+ "/usr/bin/open", with: ["-gj", Constants.AppPaths.selfService]
+ )
+
+ await waitForStore(at: storePath, toChangeFrom: before)
+ await quitSelfService()
+ }
+
+ private func isSelfServiceRunning() async -> Bool {
+ // `pgrep` exits non-zero when nothing matches, which ProcessRunner turns into a throw, so a
+ // value back means a match.
+ let found = try? await ExecutionService.executeCommand(
+ "/usr/bin/pgrep", with: ["-x", Self.selfServiceProcessPattern]
+ )
+
+ return found != nil
+ }
+
+ /// Ask Self Service+ to quit, and insist only if it does not.
+ ///
+ /// It used to be sent `SIGKILL` outright, which can cut off the store write this refresh exists to
+ /// read.
+ private func quitSelfService() async {
+ _ = try? await ExecutionService.executeCommand(
+ "/usr/bin/pkill", with: ["-TERM", Self.selfServiceProcessName]
+ )
+
+ for _ in 0..<12 {
+ try? await Task.sleep(for: .milliseconds(250))
+ if !(await isSelfServiceRunning()) { return }
+ }
+
+ Logger.shared.logDebug("Self Service+ did not quit when asked; stopping it")
+ _ = try? await ExecutionService.executeCommand(
+ "/usr/bin/pkill", with: ["-9", Self.selfServiceProcessName]
+ )
+ }
+
+ private func storeModifiedDate(at path: String) -> Date? {
+ (try? FileManager.default.attributesOfItem(atPath: path))?[.modificationDate] as? Date
+ }
+
+ /// Wait until Self Service+ has rewritten its store, rather than guessing at how long that takes.
+ ///
+ /// The previous fixed two seconds was both too short to see new data and long enough to be felt on
+ /// every refresh.
+ private func waitForStore(at path: String, toChangeFrom before: Date?) async {
+ let deadline = Date().addingTimeInterval(Self.storeRefreshTimeout)
+
+ while Date() < deadline {
+ try? await Task.sleep(for: .milliseconds(250))
+
+ if let current = storeModifiedDate(at: path), current != before {
+ // The mtime changes when the write starts, so let it finish before anything reads it.
+ try? await Task.sleep(for: .milliseconds(500))
+ return
+ }
+ }
+
+ Logger.shared.logDebug(
+ "Self Service+ did not rewrite its store within \(Int(Self.storeRefreshTimeout))s; using what is there"
+ )
+ }
+
+ // MARK: - Running state and execution
+
+ func isRunning(patchId: Int) -> Bool {
+ runningUpdateIds.contains(patchId)
+ }
+
+ func runPatch(patchId: Int, userId: String? = nil) async {
+ markRunning(patchId)
+ defer { markFinished(patchId) }
+
+ // Taken before the install, because afterwards this patch should be on its way out of the list.
+ let installingVersion = appState.pendingJamfUpdates
+ .first { $0.patchId == patchId }?.version
+
+ do {
+ // The helper runs the patch as whichever user connected to it, which it reads from the
+ // audit token rather than taking our word for it.
+ _ = try await ExecutionService.jamfSelfServicePatch(id: String(patchId), userId: userId)
+
+ if let installingVersion {
+ recentlyInstalled.record(patchId: patchId, version: installingVersion)
+ }
+ } catch {
+ Logger.shared.logError("Failed to run patch \(patchId): \(error)")
+ }
+
+ await getPendingJamfUpdates()
+ }
+
+ // MARK: - Updates installed but still listed
+
+ /// Patches installed from this app that Self Service+'s store still offers.
+ private var recentlyInstalled = RecentlyInstalledPatches()
+
+ /// Hide patches this app has just installed, until the store agrees they are done.
+ func withoutRecentlyInstalled(_ updates: [PendingJamfUpdate], now: Date = Date()) -> [PendingJamfUpdate] {
+ recentlyInstalled.filtering(updates, now: now)
+ }
+}
+
+/// Keeps track of patches installed from this app that Self Service+'s store has not caught up with.
+///
+/// The store is Self Service+'s own cache and only changes when Self Service+ runs, so a patch installed
+/// a moment ago keeps appearing in it. Re-deriving the list straight from the store would put the row
+/// back with its Update button, which reads as the install having failed. Modelled on the Fleet side's
+/// `installedVersionsAwaitingInventory`, which covers the same lag.
+///
+/// Its own type rather than state on the manager, so the rule can be exercised without an app state.
+struct RecentlyInstalledPatches {
+
+ /// How long a patch stays hidden when the store never drops it.
+ ///
+ /// Long enough to outlast a Self Service+ refresh, short enough that an install which reported
+ /// success without taking effect comes back rather than staying hidden.
+ static let timeout: TimeInterval = 30 * 60
+
+ private var versions: [Int: String] = [:]
+ private var since: [Int: Date] = [:]
+
+ var isEmpty: Bool { versions.isEmpty }
+
+ /// Note that `version` of `patchId` was installed, so it stops being offered as an update.
+ mutating func record(patchId: Int, version: String, at date: Date = Date()) {
+ versions[patchId] = version
+ since[patchId] = date
+ }
+
+ /// The updates worth showing, forgetting anything the store has caught up with or waited out.
+ mutating func filtering(_ updates: [PendingJamfUpdate], now: Date = Date()) -> [PendingJamfUpdate] {
+ for (patchId, version) in versions {
+ let stillOffered = updates.contains { $0.patchId == patchId && $0.version == version }
+ let waitedTooLong = now.timeIntervalSince(since[patchId] ?? now) >= Self.timeout
+
+ // Gone from the store means Self Service+ has caught up. Waited too long means it has not,
+ // and the update is worth showing again rather than hiding indefinitely.
+ guard !stillOffered || waitedTooLong else { continue }
+
+ if waitedTooLong && stillOffered {
+ Logger.shared.logDebug(
+ "Patch \(patchId) is still offered at \(version) long after installing it; showing it again"
+ )
+ }
+
+ versions[patchId] = nil
+ since[patchId] = nil
+ }
+
+ return updates.filter { update in
+ guard let patchId = update.patchId else { return true }
+ return versions[patchId] != update.version
+ }
+ }
+}
diff --git a/SupportCompanion/ViewModels/PendingMunkiUpdatesManager.swift b/SupportCompanion/ViewModels/PendingMunkiUpdatesManager.swift
index 207a0a3..9395646 100644
--- a/SupportCompanion/ViewModels/PendingMunkiUpdatesManager.swift
+++ b/SupportCompanion/ViewModels/PendingMunkiUpdatesManager.swift
@@ -8,162 +8,63 @@
import Foundation
import Combine
-class PendingMunkiUpdatesManager {
- private var appState: AppStateManager
+class PendingMunkiUpdatesManager: PendingUpdatesManager {
private let munkiApps = MunkiApps()
- private var updateCheckTimer: Timer?
- private var fetchListTimer: Timer?
- private var installPercentageTask: Task?
- private var isInstallPercentageTaskRunning = false
- init(appState: AppStateManager) {
- self.appState = appState
- }
-
- // MARK: - Install Percentage Logic
+ // MARK: - Install Percentage
- func getMunkiInstallPercentage() async {
+ override func getInstallPercentage() async {
Logger.shared.logDebug("Getting Munki install percentage")
- do {
- // Fetch counts concurrently
- async let installedCount = munkiApps.getInstalledAppsCount()
- async let pendingCount = munkiApps.getPendingUpdates()
- let (installed, pending) = await (installedCount, pendingCount)
-
- let totalApps = installed + pending
- let newInstallPercentage = totalApps > 0
- ? (Double(installed) / Double(totalApps)) * 100
- : 0.0
-
- // Update only if the percentage has changed
- if newInstallPercentage != appState.installPercentage {
- DispatchQueue.main.async {
- self.appState.installedAppsCount = installed
- self.appState.pendingUpdatesCount = pending
- self.appState.installPercentage = newInstallPercentage
- Logger.shared.logDebug("Install percentage updated: \(self.appState.installPercentage)%")
- }
- } else {
- Logger.shared.logDebug("Install percentage unchanged: \(self.appState.installPercentage)%")
- }
- }
- }
-
- func startInstallPercentageTask() {
- guard !isInstallPercentageTaskRunning else {
- Logger.shared.logDebug("Install percentage task already running")
- return
- }
-
- isInstallPercentageTaskRunning = true
- Logger.shared.logDebug("Starting install percentage task")
-
- installPercentageTask = Task {
- await self.getMunkiInstallPercentage()
- isInstallPercentageTaskRunning = false
- }
- }
-
- func stopInstallPercentageTask() {
- Logger.shared.logDebug("Stopping install percentage task")
- installPercentageTask?.cancel()
- installPercentageTask = nil
- isInstallPercentageTaskRunning = false
- }
-
- // MARK: - Pending Updates Logic
-
- func fetchPendingUpdatesList() async {
- do {
- let updates = await munkiApps.getPendingUpdatesList()
- DispatchQueue.main.async {
- guard updates != self.appState.pendingMunkiUpdates else {
- Logger.shared.logDebug("Pending updates list unchanged")
- return
- }
- self.appState.pendingMunkiUpdates = updates
- Logger.shared.logDebug("Updated pending updates list")
- }
+ async let installedCount = munkiApps.getInstalledAppsCount()
+ async let pendingCount = munkiApps.getPendingUpdates()
+ let (installed, pending) = await (installedCount, pendingCount)
+
+ let totalApps = installed + pending
+ let newInstallPercentage = totalApps > 0
+ ? (Double(installed) / Double(totalApps)) * 100
+ : 0.0
+
+ if newInstallPercentage != appState.installPercentage {
+ appState.installedAppsCount = installed
+ appState.pendingUpdatesCount = pending
+ appState.installPercentage = newInstallPercentage
+ Logger.shared.logDebug("Install percentage updated: \(appState.installPercentage)%")
+ } else {
+ Logger.shared.logDebug("Install percentage unchanged: \(appState.installPercentage)%")
}
}
-
- func startFetchingList(interval: TimeInterval = 60) {
- Logger.shared.logDebug("Starting periodic fetch of pending updates list")
- // Run the task immediately
- Task {
- await fetchPendingUpdatesList()
- }
+ // MARK: - Presentation
- // Start a periodic timer
- stopFetchingList()
- fetchListTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
- Logger.shared.logDebug("Timer triggered fetch pending updates list task")
- Task {
- await self?.fetchPendingUpdatesList()
- }
- }
- }
+ override var pendingUpdates: [any PendingUpdate] { appState.pendingMunkiUpdates }
- func stopFetchingList() {
- Logger.shared.logDebug("Stopping periodic fetch of pending updates list")
- fetchListTimer?.invalidate()
- fetchListTimer = nil
+ override func managementApp(forUpdates: Bool) -> (name: String, path: String) {
+ forUpdates ? ("MSC Updates", Constants.AppPaths.MSCUpdates) : ("MSC", Constants.AppPaths.MSC)
}
- func fetchPendingUpdates() async {
- do {
- let updates = await munkiApps.getPendingUpdates()
- DispatchQueue.main.async {
- if self.appState.pendingUpdatesCount != updates {
- self.appState.pendingUpdatesCount = updates
- }
- }
- if updates > 0 && !appState.preferences.hiddenCards.contains("PendingAppUpdates") {
- NotificationService(appState: appState).sendNotification(
- message: appState.preferences.appUpdateNotificationMessage,
- buttonText: appState.preferences.appUpdateNotificationButtonText,
- command: appState.preferences.appUpdateNotificationCommand,
- notificationType: .appUpdate
- )
- }
+ // MARK: - Pending Updates
- DispatchQueue.main.async {
- self.appState.pendingUpdatesCount = updates
- }
+ override func fetchPendingUpdatesList() async {
+ let updates = await munkiApps.getPendingUpdatesList()
+ guard updates != appState.pendingMunkiUpdates else {
+ Logger.shared.logDebug("Pending updates list unchanged")
+ return
}
+ appState.pendingMunkiUpdates = updates
+ Logger.shared.logDebug("Updated pending updates list")
}
- // MARK: - Timer Logic
-
- func startUpdateCheckTimer() {
- Logger.shared.logDebug("Starting app update check timer")
-
- // Run the task immediately
- Task {
- await fetchPendingUpdates()
+ override func fetchPendingUpdates() async {
+ let updates = await munkiApps.getPendingUpdates()
+ appState.pendingUpdatesCount = updates
+ if updates > 0 && !appState.preferences.hiddenCards.contains(Constants.Cards.pendingAppUpdates) {
+ NotificationService(appState: appState).sendNotification(
+ message: appState.preferences.notifications.appUpdateNotificationMessage,
+ buttonText: appState.preferences.notifications.appUpdateNotificationButtonText,
+ command: appState.preferences.notifications.appUpdateNotificationCommand,
+ notificationType: .appUpdate
+ )
}
-
- // Set up the timer to run every hour
- updateCheckTimer?.invalidate() // Stop any existing timer
- updateCheckTimer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
- Task {
- await self?.fetchPendingUpdates()
- }
- }
- }
-
- func stopUpdateCheckTimer() {
- Logger.shared.logDebug("Stopping app update check timer")
- updateCheckTimer?.invalidate()
- updateCheckTimer = nil
- }
-
- // MARK: - Cleanup
-
- deinit {
- stopInstallPercentageTask()
- stopFetchingList()
}
}
diff --git a/SupportCompanion/ViewModels/PendingUpdatesManager.swift b/SupportCompanion/ViewModels/PendingUpdatesManager.swift
new file mode 100644
index 0000000..0a8ae40
--- /dev/null
+++ b/SupportCompanion/ViewModels/PendingUpdatesManager.swift
@@ -0,0 +1,154 @@
+//
+// PendingUpdatesManager.swift
+// SupportCompanion
+//
+// Created as a base class for all pending-update managers.
+//
+
+import Foundation
+import Observation
+
+/// Shared timer/task lifecycle for all pending-update managers.
+/// Subclasses override `fetchPendingUpdates()`, `fetchPendingUpdatesList()`, and
+/// `getInstallPercentage()` to provide MDM-specific data-fetching logic.
+@MainActor
+@Observable
+class PendingUpdatesManager {
+ let appState: AppStateManager
+ @ObservationIgnored private var updateCheckTask: Task?
+ @ObservationIgnored private var fetchListTask: Task?
+ @ObservationIgnored private var installPercentageTask: Task?
+ @ObservationIgnored private var isInstallPercentageTaskRunning = false
+
+ /// Ids of updates currently being installed from the app, so views can show progress.
+ /// Kept in the base class because subclasses can't add observed properties to an @Observable class.
+ private(set) var runningUpdateIds: Set = []
+
+ func markRunning(_ id: Int) {
+ runningUpdateIds.insert(id)
+ }
+
+ func markFinished(_ id: Int) {
+ runningUpdateIds.remove(id)
+ }
+
+ init(appState: AppStateManager) {
+ self.appState = appState
+ }
+
+ // MARK: - Override points
+
+ /// Fetch the pending-update count and send a notification if updates are present.
+ func fetchPendingUpdates() async {}
+
+ /// Fetch the full pending-update list.
+ func fetchPendingUpdatesList() async {}
+
+ /// Compute and publish the install-percentage value.
+ func getInstallPercentage() async {}
+
+ /// The pending updates last fetched by `fetchPendingUpdatesList()`.
+ var pendingUpdates: [any PendingUpdate] { [] }
+
+ /// Title of an extra column in the pending updates list, or nil for none.
+ var pendingUpdatesDetailColumnTitle: String? { nil }
+
+ /// The app users open to manage software, used by "Open …" buttons and update notifications.
+ /// `forUpdates` selects the updates view where the app has a separate one.
+ func managementApp(forUpdates: Bool) -> (name: String, path: String) {
+ ("Unknown App", "")
+ }
+
+ /// Whether this mode's catalog already offers the application an installer would install.
+ ///
+ /// The default is nil, meaning "no claim": a mode that cannot inspect its catalog should say
+ /// nothing rather than guess. A mode that can implements this by mapping its own catalog to
+ /// `CatalogEntry` and handing it to `CatalogMatching` — the matching rule stays in one place, and
+ /// adding Munki or Jamf later is an override here rather than a change anywhere that displays it.
+ func catalogSuggestion(for facts: InstallerFacts) -> CatalogSuggestion? { nil }
+
+ /// Helper for the above: match against this mode's catalog and point at this mode's software app.
+ final func suggestion(matching facts: InstallerFacts, in entries: [CatalogEntry]) -> CatalogSuggestion? {
+ guard let match = CatalogMatching.match(facts, in: entries) else { return nil }
+
+ let destination = managementApp(forUpdates: false)
+
+ return CatalogSuggestion(
+ name: match.name,
+ destinationName: destination.name,
+ destinationPath: destination.path
+ )
+ }
+
+ /// Title of the button that opens the management app.
+ func openManagementAppTitle(forUpdates: Bool) -> String {
+ "\(Constants.Actions.openManagementApp) \(managementApp(forUpdates: forUpdates).name)"
+ }
+
+ // MARK: - Shared timer / task management
+
+ final func startUpdateCheckTimer() {
+ Logger.shared.logDebug("Starting app update check timer")
+ stopUpdateCheckTimer()
+ updateCheckTask = Task {
+ await fetchPendingUpdates()
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(3600))
+ guard !Task.isCancelled else { break }
+ await fetchPendingUpdates()
+ }
+ }
+ }
+
+ final func stopUpdateCheckTimer() {
+ Logger.shared.logDebug("Stopping app update check timer")
+ updateCheckTask?.cancel()
+ updateCheckTask = nil
+ }
+
+ final func startFetchingList(interval: TimeInterval = 60) {
+ Logger.shared.logDebug("Starting periodic fetch of pending updates list")
+ stopFetchingList()
+ fetchListTask = Task {
+ await fetchPendingUpdatesList()
+ while !Task.isCancelled {
+ try? await Task.sleep(for: .seconds(interval))
+ guard !Task.isCancelled else { break }
+ Logger.shared.logDebug("Task loop triggered fetch pending updates list")
+ await fetchPendingUpdatesList()
+ }
+ }
+ }
+
+ final func stopFetchingList() {
+ Logger.shared.logDebug("Stopping periodic fetch of pending updates list")
+ fetchListTask?.cancel()
+ fetchListTask = nil
+ }
+
+ final func startInstallPercentageTask() {
+ guard !isInstallPercentageTaskRunning else {
+ Logger.shared.logDebug("Install percentage task already running")
+ return
+ }
+ isInstallPercentageTaskRunning = true
+ Logger.shared.logDebug("Starting install percentage task")
+ installPercentageTask = Task {
+ await getInstallPercentage()
+ isInstallPercentageTaskRunning = false
+ }
+ }
+
+ final func stopInstallPercentageTask() {
+ Logger.shared.logDebug("Stopping install percentage task")
+ installPercentageTask?.cancel()
+ installPercentageTask = nil
+ isInstallPercentageTaskRunning = false
+ }
+
+ deinit {
+ updateCheckTask?.cancel()
+ fetchListTask?.cancel()
+ installPercentageTask?.cancel()
+ }
+}
diff --git a/SupportCompanion/ViewModels/ReasonInputManager.swift b/SupportCompanion/ViewModels/ReasonInputManager.swift
index 4a27b9f..4184d27 100644
--- a/SupportCompanion/ViewModels/ReasonInputManager.swift
+++ b/SupportCompanion/ViewModels/ReasonInputManager.swift
@@ -1,8 +1,9 @@
-import SwiftUI
import AppKit
+import SwiftUI
class ReasonInputManager {
private var window: NSWindow?
+ private var windowDelegate: WindowDelegate?
static let shared = ReasonInputManager()
@@ -40,10 +41,13 @@ class ReasonInputManager {
self.window = newWindow
- // Handle manual closure via delegate
- newWindow.delegate = WindowDelegate { [weak self] in
+ let delegate = WindowDelegate { [weak self] in
self?.closeWindow()
}
+
+ // Handle manual closure via delegate
+ newWindow.delegate = delegate
+ self.windowDelegate = delegate
}
func closeWindow() {
@@ -51,9 +55,13 @@ class ReasonInputManager {
return
}
- window.orderOut(nil) // Explicitly remove from the screen
- window.close() // Close the window
+ // Clear state before closing: `close()` sends `windowWillClose`, which routes straight
+ // back here, and re-entering with `window` still set would close the window twice.
self.window = nil
+ self.windowDelegate = nil
+ window.delegate = nil
+ window.orderOut(nil)
+ window.close()
}
}
@@ -72,4 +80,4 @@ private class WindowDelegate: NSObject, NSWindowDelegate {
private class CustomWindow: NSWindow {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { true }
-}
\ No newline at end of file
+}
diff --git a/SupportCompanion/ViewModels/SSOInfoManager.swift b/SupportCompanion/ViewModels/SSOInfoManager.swift
index 2096c4b..b4414f2 100644
--- a/SupportCompanion/ViewModels/SSOInfoManager.swift
+++ b/SupportCompanion/ViewModels/SSOInfoManager.swift
@@ -6,8 +6,11 @@
//
import Foundation
+import Observation
-class SSOInfoManager: ObservableObject {
+@MainActor
+@Observable
+class SSOInfoManager {
static let shared = SSOInfoManager(
kerberosSSO: KerberosSSO(
id: UUID(),
@@ -29,8 +32,8 @@ class SSOInfoManager: ObservableObject {
)
)
- @Published var kerberosSSO: KerberosSSO
- @Published var platformSSO: PlatformSSO
+ var kerberosSSO: KerberosSSO
+ var platformSSO: PlatformSSO
private let helper = SSOInfoHelpers()
@@ -67,9 +70,7 @@ class SSOInfoManager: ObservableObject {
private func updateKerberosSSO() async {
do {
let kerberosDetails = try await helper.fetchKerberosSSO()
- DispatchQueue.main.async {
- self.kerberosSSO = kerberosDetails
- }
+ self.kerberosSSO = kerberosDetails
} catch {
Logger.shared.logError("Failed to update Kerberos SSO Info: \(error.localizedDescription)")
}
@@ -78,9 +79,7 @@ class SSOInfoManager: ObservableObject {
private func updatePlatformSSO() async {
do {
let platformDetails = try await helper.fetchPlatformSSO()
- DispatchQueue.main.async {
- self.platformSSO = platformDetails
- }
+ self.platformSSO = platformDetails
} catch {
Logger.shared.logError("Failed to update Platform SSO Info: \(error.localizedDescription)")
}
diff --git a/SupportCompanion/ViewModels/StorageInfoManager.swift b/SupportCompanion/ViewModels/StorageInfoManager.swift
index 4e6a7b3..dddec6a 100644
--- a/SupportCompanion/ViewModels/StorageInfoManager.swift
+++ b/SupportCompanion/ViewModels/StorageInfoManager.swift
@@ -6,12 +6,13 @@
//
import Foundation
+import Observation
import Combine
import SwiftUI
-class StorageInfoManager: ObservableObject {
- @Environment(\.colorScheme) var colorScheme
-
+@MainActor
+@Observable
+class StorageInfoManager {
static let shared = StorageInfoManager(
storageInfo: StorageInfo(
id: UUID(),
@@ -21,7 +22,7 @@ class StorageInfoManager: ObservableObject {
)
)
- @Published var storageInfo: StorageInfo
+ var storageInfo: StorageInfo
init(storageInfo: StorageInfo) {
self.storageInfo = storageInfo
@@ -44,24 +45,12 @@ class StorageInfoManager: ObservableObject {
self.updateStorageInfo()
}
- func getPercentageColor(percentage: Double) -> Color {
- if percentage < 50 {
- return .ScGreen
- } else if percentage < 80 {
- return colorScheme == .light ? .orangeLight : .orange
- } else {
- return colorScheme == .light ? .redLight : .red
- }
- }
-
func updateStorageInfo(usagePercentage: Double = getStorageUsagePercentage()) {
- DispatchQueue.main.async {
- self.storageInfo = StorageInfo(
- id: UUID(),
- name: getStorageName(),
- fileVault: isFileVaultEnabled(),
- usage: usagePercentage
- )
- }
+ self.storageInfo = StorageInfo(
+ id: UUID(),
+ name: getStorageName(),
+ fileVault: isFileVaultEnabled(),
+ usage: usagePercentage
+ )
}
}
diff --git a/SupportCompanion/ViewModels/SystemUpdatesManager.swift b/SupportCompanion/ViewModels/SystemUpdatesManager.swift
index 62445e2..822dd9e 100644
--- a/SupportCompanion/ViewModels/SystemUpdatesManager.swift
+++ b/SupportCompanion/ViewModels/SystemUpdatesManager.swift
@@ -6,11 +6,14 @@
//
import Foundation
+import Observation
-class SystemUpdatesManager: ObservableObject {
+@MainActor
+@Observable
+class SystemUpdatesManager {
private let appState: AppStateManager
private var previousUpdateCount: Int = 0
- private var monitorTask: Task? // Track the monitoring task
+ @ObservationIgnored private var monitorTask: Task? // Track the monitoring task
init(appState: AppStateManager) {
self.appState = appState
@@ -22,8 +25,8 @@ class SystemUpdatesManager: ObservableObject {
do {
let result = await ActionHelpers.getSystemUpdateStatus()
switch result {
- case .success(let (count, updates)):
- updateCache(count: count, updates: updates)
+ case .success(let (count, updates, hasBackgroundSecurityImprovement)):
+ updateCache(count: count, updates: updates, hasBackgroundSecurityImprovement: hasBackgroundSecurityImprovement)
case .failure(let error):
Logger.shared.logError("Failed to refresh system updates: \(error.localizedDescription)")
}
@@ -38,12 +41,12 @@ class SystemUpdatesManager: ObservableObject {
monitorTask = Task {
while !Task.isCancelled {
do {
- let result = await ActionHelpers.getSystemUpdateStatus(sendNotification: !appState.preferences.hiddenActions.contains("SoftwareUpdates"))
+ let result = await ActionHelpers.getSystemUpdateStatus(sendNotification: !appState.preferences.hiddenActions.contains(Constants.Actions.HideStrings.softwareUpdate))
switch result {
- case .success(let (count, updates)):
+ case .success(let (count, updates, hasBackgroundSecurityImprovement)):
if count != self.previousUpdateCount {
self.previousUpdateCount = count
- updateCache(count: count, updates: updates)
+ updateCache(count: count, updates: updates, hasBackgroundSecurityImprovement: hasBackgroundSecurityImprovement)
}
case .failure(let error):
Logger.shared.logError("Monitoring failed to get system updates: \(error.localizedDescription)")
@@ -62,10 +65,7 @@ class SystemUpdatesManager: ObservableObject {
}
/// Updates the cache in `AppStateManager`.
- private func updateCache(count: Int, updates: [String]) {
- Task { @MainActor in
- let newInfo = SystemUpdates(id: UUID(), count: count, updates: updates)
- self.appState.systemUpdateCache = newInfo
- }
+ private func updateCache(count: Int, updates: [String], hasBackgroundSecurityImprovement: Bool) {
+ appState.systemUpdateCache = SystemUpdates(id: UUID(), count: count, updates: updates, hasBackgroundSecurityImprovement: hasBackgroundSecurityImprovement)
}
}
diff --git a/SupportCompanion/ViewModels/UserInfoManager.swift b/SupportCompanion/ViewModels/UserInfoManager.swift
index 0fc1b36..4e1198a 100644
--- a/SupportCompanion/ViewModels/UserInfoManager.swift
+++ b/SupportCompanion/ViewModels/UserInfoManager.swift
@@ -6,8 +6,11 @@
//
import Foundation
+import Observation
-class UserInfoManager: ObservableObject {
+@MainActor
+@Observable
+class UserInfoManager {
static let shared = UserInfoManager(
userInfo: UserInfo(
login: "",
@@ -18,7 +21,7 @@ class UserInfoManager: ObservableObject {
)
)
- @Published var userInfo: UserInfo
+ var userInfo: UserInfo
private let helper = UserInfoHelper()
@@ -33,9 +36,7 @@ class UserInfoManager: ObservableObject {
func updateUserInfo() {
Task {
let userDetails = try await helper.fetchUserInfo()
- DispatchQueue.main.async {
- self.userInfo = userDetails
- }
+ self.userInfo = userDetails
}
}
}
diff --git a/SupportCompanion/ViewModels/UserInstallManager.swift b/SupportCompanion/ViewModels/UserInstallManager.swift
new file mode 100644
index 0000000..d858628
--- /dev/null
+++ b/SupportCompanion/ViewModels/UserInstallManager.swift
@@ -0,0 +1,301 @@
+//
+// UserInstallManager.swift
+// SupportCompanion
+//
+
+import AppKit
+import Foundation
+import Observation
+import SwiftUI
+
+/// Drives the window a user sees after opening an installer.
+///
+/// Every decision here is the helper's, fetched once by staging the file. This object knows whether to
+/// show a window, what to put in it, and what to do when a button is pressed — it does not decide
+/// whether anything may be installed, and its view of the allowlist is not consulted anywhere.
+@MainActor
+@Observable
+final class UserInstallManager {
+
+ static let shared = UserInstallManager()
+
+ enum Stage: Equatable {
+ case assessing(fileName: String)
+ case allowed(InstallerAssessment)
+ case refused(InstallerAssessment)
+ case installing(InstallerAssessment)
+ case installed(InstallerAssessment)
+ case failed(assessment: InstallerAssessment?, message: String)
+ }
+
+ private(set) var stage: Stage?
+
+ @ObservationIgnored private var url: URL?
+ @ObservationIgnored private var window: NSWindow?
+
+ private init() {}
+
+ // MARK: Opening
+
+ /// Take over an installer the user opened.
+ ///
+ /// Anything that goes wrong here hands the file back to the system rather than stopping: a Mac
+ /// where installers cannot be opened at all would be a far worse outcome than one where this
+ /// feature is not helping.
+ func open(_ url: URL) {
+ guard AppStateManager.shared.preferences.enableUserInstalls else {
+ // Said out loud: this used to be the quietest of three ways to end up back in
+ // Installer.app, which made a misconfigured feature look exactly like a working one.
+ Logger.shared.logInfo(
+ "EnableUserInstalls is not set, so \(url.lastPathComponent) goes to the system handler"
+ )
+ handOffToSystem(url)
+ return
+ }
+
+ self.url = url
+ stage = .assessing(fileName: url.lastPathComponent)
+ showWindow()
+
+ Task {
+ do {
+ let assessment = try await ExecutionService.stageInstaller(at: url)
+
+ if assessment.isAllowed {
+ stage = .allowed(assessment)
+ return
+ }
+
+ Logger.shared.logInfo(
+ "\(url.lastPathComponent) is not allowed by an administrator: \(assessment.rejectionReasons.joined(separator: "; "))"
+ )
+
+ // Always said out loud, whatever the fallback is. Handing the file quietly to
+ // Installer.app leaves someone who cannot type an administrator password staring at a
+ // prompt, with the actual reason sitting in a log only root can read. The fallback
+ // decides which way out is offered, not whether the user is told anything.
+ stage = .refused(assessment)
+ } catch {
+ // Not a silent hand-off. The helper is the only thing that can answer this question,
+ // and if it did not, the user is about to be asked for an administrator password they
+ // do not have — so say why, and leave them the same button they would have had.
+ Logger.shared.logError("Unable to assess \(url.lastPathComponent): \(error.localizedDescription)")
+ stage = .failed(assessment: nil, message: error.localizedDescription)
+ }
+ }
+ }
+
+ /// Open the file the way the system would if this app were not on the Mac.
+ private func handOffToSystem(_ url: URL) {
+ let handler: String
+
+ switch url.pathExtension.lowercased() {
+ case "dmg":
+ handler = "/System/Library/CoreServices/DiskImageMounter.app"
+ default:
+ handler = "/System/Library/CoreServices/Installer.app"
+ }
+
+ guard FileManager.default.fileExists(atPath: handler) else {
+ NSWorkspace.shared.open(url)
+ return
+ }
+
+ NSWorkspace.shared.open(
+ [url],
+ withApplicationAt: URL(fileURLWithPath: handler),
+ configuration: NSWorkspace.OpenConfiguration()
+ ) { _, error in
+ if let error {
+ Logger.shared.logError("Unable to hand \(url.lastPathComponent) to \(handler): \(error.localizedDescription)")
+ }
+ }
+ }
+
+ // MARK: Installing
+
+ func install() {
+ guard case .allowed(let assessment) = stage, let token = assessment.token else { return }
+
+ guard assessment.requiresAuthentication else {
+ performInstall(assessment, token: token)
+ return
+ }
+
+ // The point of the feature is that no administrator password is typed. Replacing that with
+ // nothing would make an unlocked Mac enough, so the user confirms with their own credentials.
+ authenticateWithTouchIDOrPassword(
+ completion: { [weak self] success in
+ Task { @MainActor in
+ guard let self else { return }
+
+ guard success else {
+ Logger.shared.logError("Authentication failed; \(assessment.facts.fileName) was not installed")
+ self.cancel()
+ return
+ }
+
+ self.performInstall(assessment, token: token)
+ }
+ },
+ reason: "authenticate to install \(assessment.facts.displayName ?? assessment.facts.fileName)"
+ )
+ }
+
+ private func performInstall(_ assessment: InstallerAssessment, token: String) {
+ stage = .installing(assessment)
+
+ Task {
+ do {
+ let output = try await ExecutionService.installStagedInstaller(token: token)
+ Logger.shared.logInfo("Installed \(assessment.facts.fileName): \(output)")
+ stage = .installed(assessment)
+ } catch {
+ Logger.shared.logError("Failed to install \(assessment.facts.fileName): \(error.localizedDescription)")
+ stage = .failed(assessment: assessment, message: error.localizedDescription)
+ }
+ }
+ }
+
+ // MARK: The supported route
+
+ /// Whether the organisation already offers what the user just tried to install.
+ ///
+ /// Asked of `activeUpdatesManager`, so it answers for whichever mode is configured and this knows
+ /// nothing about any of them. A mode with no catalog returns nil and nothing is offered.
+ func catalogSuggestion(for facts: InstallerFacts) -> CatalogSuggestion? {
+ AppStateManager.shared.activeUpdatesManager?.catalogSuggestion(for: facts)
+ }
+
+ /// Send the user where the approved copy lives.
+ ///
+ /// The destination is whatever the mode's `managementApp` reports, which is this app's own Apps
+ /// page for Fleet and a separate application for Munki or Jamf — so both shapes are handled here
+ /// rather than assumed.
+ func showCatalog(_ suggestion: CatalogSuggestion) {
+ closeWindow()
+
+ let path = suggestion.destinationPath
+
+ if path.hasPrefix("supportcompanion://") {
+ AppStateManager.shared.showWindowCallback?()
+
+ if let url = URL(string: path) {
+ NotificationCenter.default.post(name: .handleIncomingURL, object: url)
+ }
+
+ return
+ }
+
+ // A scheme of somebody else's, such as Munki's munki://updates.html, or a path on disk.
+ if let url = URL(string: path), url.scheme != nil {
+ NSWorkspace.shared.open(url)
+ } else if !path.isEmpty {
+ NSWorkspace.shared.open(URL(fileURLWithPath: path))
+ }
+ }
+
+ // MARK: Fallbacks
+
+ /// Offer the existing time-limited elevation instead, for an installer nobody allowed.
+ ///
+ /// `ElevationManager.shared`, never a fresh one: `handleElevation` returns as soon as it has asked
+ /// for authentication, and its completion holds the manager weakly. A manager owned by this method
+ /// is released the moment the method returns, and everything after the user authenticates —
+ /// logging the reason, and starting the countdown the UI reads — is silently skipped.
+ func elevate() {
+ guard let url else { return }
+
+ let appState = AppStateManager.shared
+
+ closeWindow()
+
+ if appState.preferences.elevation.requireReasonForElevation {
+ ReasonInputManager.shared.presentAsWindow(isPresented: .constant(true)) { reason in
+ ElevationManager.shared.handleElevation(reason: reason) { granted in
+ Self.finishElevation(granted: granted, for: url)
+ }
+ }
+ } else {
+ ElevationManager.shared.handleElevation(reason: "") { granted in
+ Self.finishElevation(granted: granted, for: url)
+ }
+ }
+ }
+
+ /// Hand the installer back once the rights it needed exist.
+ ///
+ /// Only after the grant. Opening it any earlier — which is what happened while this ran alongside
+ /// the reason prompt — puts the file in front of someone who still cannot install it.
+ @MainActor
+ private static func finishElevation(granted: Bool, for url: URL) {
+ guard granted else {
+ Logger.shared.logError("Elevation was not granted; \(url.lastPathComponent) was not reopened")
+ return
+ }
+
+ Logger.shared.logInfo("Elevation granted, reopening \(url.lastPathComponent)")
+ shared.handOffToSystem(url)
+ }
+
+ func openInInstaller() {
+ guard let url else { return }
+ closeWindow()
+ handOffToSystem(url)
+ }
+
+ // MARK: Dismissal
+
+ func cancel() {
+ if let token = currentToken {
+ Task { try? await ExecutionService.discardStagedInstaller(token: token) }
+ }
+
+ closeWindow()
+ }
+
+ private var currentToken: String? {
+ switch stage {
+ case .allowed(let assessment), .refused(let assessment):
+ return assessment.token
+ default:
+ return nil
+ }
+ }
+
+ // MARK: Window
+
+ private func showWindow() {
+ guard window == nil else {
+ window?.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ return
+ }
+
+ let controller = NSHostingController(
+ rootView: UserInstallView(manager: self)
+ .environment(AppStateManager.shared)
+ )
+
+ let newWindow = NSWindow(contentViewController: controller)
+ newWindow.setContentSize(NSSize(width: 520, height: 400))
+ newWindow.styleMask = [.titled, .closable, .fullSizeContentView]
+ newWindow.titlebarAppearsTransparent = true
+ newWindow.title = ""
+ newWindow.isReleasedWhenClosed = false
+ newWindow.center()
+ newWindow.level = .floating
+
+ window = newWindow
+ newWindow.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ func closeWindow() {
+ stage = nil
+ url = nil
+ window?.orderOut(nil)
+ window?.close()
+ window = nil
+ }
+}
diff --git a/SupportCompanion/Views/Applications.swift b/SupportCompanion/Views/Applications.swift
index 4a55a84..5d6397a 100644
--- a/SupportCompanion/Views/Applications.swift
+++ b/SupportCompanion/Views/Applications.swift
@@ -9,64 +9,33 @@ import Foundation
import SwiftUI
struct Applications: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@State private var isLoading = false
+ @State private var searchText = ""
@State private var task: Task?
-
+
+ private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
+
var body: some View {
- let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
ZStack {
if isLoading {
- // Centered ProgressView
- VStack {
- ProgressView("Loading applications...")
- .progressViewStyle(CircularProgressViewStyle())
- .padding()
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity) // Full screen
- .background(Color.clear)
- .ignoresSafeArea() // Ensure it covers the entire screen
+ loading
+ } else if appState.installedApplications.isEmpty {
+ empty
} else {
- if appState.installedApplications.isEmpty {
- VStack {
- Image(systemName: "exclamationmark.triangle")
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 50, height: 50)
- .foregroundColor(.primary)
- Text("No installed applications found")
- .font(.title)
- .foregroundColor(.primary)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity) // Full screen
- .background(Color.clear)
- .ignoresSafeArea() // Ensure it covers the entire screen
- } else {
- ScrollView {
- LazyVGrid(
- columns: columns,
- alignment: .leading
- ) {
- ForEach(appState.installedApplications) { card in
- AppCard(card: card)
- .fixedSize(horizontal: false, vertical: false) // Allow vertical expansion
- }
- }
- }
- }
+ catalog
}
}
.padding(.horizontal, 20)
- .padding(.bottom, 20)
.onAppear {
isLoading = true
let appInfoManager = ApplicationsInfoManager(appState: appState) // Local instance
-
+
task = Task {
defer { isLoading = false } // Ensure `isLoading` is reset
await appInfoManager.fetchAppsBasedOnMode()
}
-
+
appState.applicationsInfoManager.startMonitoring()
}
.onDisappear {
@@ -74,4 +43,142 @@ struct Applications: View {
appState.applicationsInfoManager.stopMonitoring()
}
}
+
+ // MARK: - States
+
+ private var loading: some View {
+ VStack {
+ ProgressView("Loading applications...")
+ .progressViewStyle(CircularProgressViewStyle())
+ .padding()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.clear)
+ .ignoresSafeArea()
+ }
+
+ private var empty: some View {
+ VStack {
+ Image(systemName: "exclamationmark.triangle")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 50, height: 50)
+ .foregroundColor(.primary)
+ Text("No installed applications found")
+ .font(.title)
+ .foregroundColor(.primary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(Color.clear)
+ .ignoresSafeArea()
+ }
+
+ // MARK: - Catalog
+
+ private var catalog: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ searchRow
+
+ let mode = appState.preferences.mode
+ if mode != Constants.Modes.systemProfiler {
+ Text("This list shows applications installed by \(mode).")
+ .font(.caption)
+ .foregroundColor(.secondary)
+ }
+
+ if matching.isEmpty {
+ Text(Constants.Apps.noMatches)
+ .foregroundColor(.secondary)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
+ } else {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 18) {
+ // Apps with an update come first: it is the only part of this page somebody has
+ // to act on, and it used to be findable only by scrolling for the one card with
+ // an Update button on it.
+ if !updatable.isEmpty {
+ section(Constants.Apps.updatesAvailable, updatable)
+ }
+
+ if !upToDate.isEmpty {
+ section(Constants.Apps.installed, upToDate)
+ }
+ }
+ .padding(.bottom, 5)
+ }
+ }
+ }
+ }
+
+ private var searchRow: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
+ TextField(Constants.Apps.searchPlaceholder, text: $searchText)
+ .textFieldStyle(.plain)
+ if !searchText.isEmpty {
+ Button {
+ searchText = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help(Constants.Apps.clearSearch)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .frame(width: 320)
+ .isGlass()
+ .cornerRadius(8)
+ }
+
+ private func section(_ name: String, _ apps: [InstalledApp]) -> some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 6) {
+ Text(name).font(.headline)
+ Text("\(apps.count)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ LazyVGrid(columns: columns, alignment: .leading) {
+ ForEach(apps) { card in
+ AppCard(card: card)
+ .fixedSize(horizontal: false, vertical: false) // Allow vertical expansion
+ }
+ }
+ }
+ }
+
+ // MARK: - Grouping
+
+ private var matching: [InstalledApp] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !query.isEmpty else { return appState.installedApplications }
+
+ return appState.installedApplications.filter {
+ $0.name.localizedCaseInsensitiveContains(query)
+ }
+ }
+
+ /// Names of installed apps an update is waiting for.
+ ///
+ /// Taken from the active manager rather than by asking which mode is configured, so a mode that
+ /// starts reporting `installedAppName` gets the section without this view changing.
+ private var appNamesWithUpdates: Set {
+ Set(
+ (appState.activeUpdatesManager?.pendingUpdates ?? [])
+ .compactMap(\.installedAppName)
+ )
+ }
+
+ private var updatable: [InstalledApp] {
+ let waiting = appNamesWithUpdates
+ return matching.filter { waiting.contains($0.name) }
+ }
+
+ private var upToDate: [InstalledApp] {
+ let waiting = appNamesWithUpdates
+ return matching.filter { !waiting.contains($0.name) }
+ }
}
diff --git a/SupportCompanion/Views/CardGrid.swift b/SupportCompanion/Views/CardGrid.swift
index 29f455d..3b42761 100644
--- a/SupportCompanion/Views/CardGrid.swift
+++ b/SupportCompanion/Views/CardGrid.swift
@@ -10,8 +10,8 @@ import SwiftUI
import AlertToast
struct CardGrid: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
@State private var showRebootModal = false
@State private var modalCountdown = Constants.RebootModal.countdown
@State private var modalTitle = Constants.RebootModal.title
@@ -23,6 +23,11 @@ struct CardGrid: View {
]
ZStack{
ScrollView {
+ if showsFleetComplianceBanner {
+ FleetComplianceBanner()
+ .padding(.horizontal, 20)
+ }
+
LazyVGrid(
columns: columns,
alignment: .leading
@@ -31,7 +36,7 @@ struct CardGrid: View {
DeviceInformationCard(viewModel: viewModel)
// Patching progress card
- if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune {
+ if appState.activeUpdatesManager != nil {
PatchingProgressCard(viewModel: viewModel)
PendingUpdatesCard(viewModel: viewModel)
}
@@ -47,7 +52,7 @@ struct CardGrid: View {
}
)
- ForEach(getVisibleStacks(viewModel: viewModel), id: \.id) { stack in
+ ForEach(viewModel.getVisibleStacks(viewModel: viewModel), id: \.id) { stack in
stack.view
.frame(maxWidth: .infinity)
}
@@ -65,7 +70,7 @@ struct CardGrid: View {
.padding(.horizontal, 20)
.padding(.bottom, 20)
.onAppear{
- if appState.preferences.customCardPath.isEmpty && appState.preferences.customCardsMenuLabel.isEmpty {
+ if !appState.preferences.customCardPath.isEmpty && appState.preferences.customCardsMenuLabel.isEmpty {
appState.refreshJsonCards()
}
}
@@ -81,15 +86,18 @@ struct CardGrid: View {
}
}
.onAppear {
- if !appState.preferences.hiddenCards.contains(Constants.CardTitle.evergreen) {
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.evergreen) && appState.preferences.mode == Constants.Modes.munki {
appState.evergreenInfoManager.refresh()
}
- if !appState.preferences.hiddenCards.contains(Constants.CardTitle.battery) {
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.battery) {
appState.batteryInfoManager.startMonitoring()
}
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && appState.preferences.mode == Constants.Modes.jamf {
+ appState.jamfInfoManager.refresh()
+ }
}
.onDisappear {
- if !appState.preferences.hiddenCards.contains(Constants.CardTitle.battery) {
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.battery) {
appState.batteryInfoManager.stopMonitoring()
}
}
@@ -110,57 +118,18 @@ struct CardGrid: View {
}
}
-func getVisibleStacks(viewModel: CardGridViewModel) -> [(id: String, view: AnyView)] {
- var visibleStacks: [(id: String, view: AnyView)] = []
-
- // Conditional logic to arrange Battery and Storage/Device stacks
- if viewModel.isCardVisible(Constants.Cards.storage) && viewModel.isCardVisible(Constants.Cards.deviceManagement) {
- // Both Storage and Device Management are visible: Split columns
- visibleStacks.append(
- (id: "StorageDeviceManagement",
- view: AnyView(
- StorageDeviceManagementStack(viewModel: viewModel)
- .frame(maxWidth: .infinity)
- .gridCellColumns(1)
- ))
- )
-
- visibleStacks.append(
- (id: "BatteryEvergreen",
- view: AnyView(
- BatteryEvergreenStack(viewModel: viewModel)
- .frame(maxWidth: .infinity)
- .gridCellColumns(1)
- ))
- )
- } else {
- // Otherwise, span the grid
- visibleStacks.append(
- (id: "BatteryEvergreen",
- view: AnyView(
- BatteryEvergreenStack(viewModel: viewModel)
- .frame(maxWidth: .infinity)
- .gridCellColumns(2)
- ))
- )
-
- visibleStacks.append(
- (id: "StorageDeviceManagement",
- view: AnyView(
- StorageDeviceManagementStack(viewModel: viewModel)
- .frame(maxWidth: .infinity)
- .gridCellColumns(2)
- ))
- )
+extension CardGrid {
+ private var showsFleetComplianceBanner: Bool {
+ appState.preferences.mode == Constants.Modes.fleet
+ && viewModel.isCardVisible(Constants.Cards.fleetPolicies)
+ && !appState.fleetDeviceManager.failingPolicies.isEmpty
}
-
- return visibleStacks
}
struct CardGridView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
- .environmentObject(AppStateManager.shared)
+ .environment(AppStateManager.shared)
.frame(width: 1500, height: 100)
}
}
diff --git a/SupportCompanion/Views/Cards/ActionsCard.swift b/SupportCompanion/Views/Cards/ActionsCard.swift
index d3a5362..6a085f7 100644
--- a/SupportCompanion/Views/Cards/ActionsCard.swift
+++ b/SupportCompanion/Views/Cards/ActionsCard.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct ActionsCard: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
var onShowRebootModal: (Int, String, String) -> Void
var body: some View {
@@ -24,17 +24,21 @@ struct ActionsCard: View {
onShowRebootModal(countdown, title, message)
}
) : nil,
- (appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune)
+ (appState.preferences.mode == Constants.Modes.munki || appState.preferences.mode == Constants.Modes.intune)
? (viewModel.isButtonVisible(Constants.Actions.HideStrings.openManagementApp) ? viewModel.createOpenManagementAppButton(type: .default) : nil)
: nil,
- viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport) ? ScButton(Constants.Actions.getSupport) { ActionHelpers.openSupportPage(url: appState.preferences.supportPageURL) } : nil,
+ viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport) && !appState.preferences.supportPageURL.isEmpty ? ScButton(
+ Constants.Actions.getSupport)
+ { await ActionHelpers.openSupportPage(url: appState.preferences.supportPageURL) } : nil,
viewModel.isButtonVisible(Constants.Actions.HideStrings.gatherLogs) ? viewModel.createGatherLogsButton() : nil,
viewModel.isButtonVisible(Constants.Actions.HideStrings.softwareUpdate) ? ScButton(
Constants.Actions.softwareUpdate,
badgeNumber: appState.systemUpdateCache.updates.count,
helpText: appState.systemUpdateCache.updates.joined(separator: "\n"))
- { ActionHelpers.openSystemUpdates() } : nil,
- (appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune)
+ { [hasBackgroundSecurityImprovement = appState.systemUpdateCache.hasBackgroundSecurityImprovement] in
+ hasBackgroundSecurityImprovement ? ActionHelpers.openBackgroundSecurityImprovements() : ActionHelpers.openSystemUpdates()
+ } : nil,
+ (appState.preferences.mode == Constants.Modes.munki || appState.preferences.mode == Constants.Modes.intune)
? (viewModel.isButtonVisible(Constants.Actions.HideStrings.restartIntuneAgent) ? viewModel.createRestartIntuneAgentButton() : nil)
: nil
].compactMap { $0 } // Remove nil values
diff --git a/SupportCompanion/Views/Cards/AppCard.swift b/SupportCompanion/Views/Cards/AppCard.swift
index 2b5ff95..c17cd27 100644
--- a/SupportCompanion/Views/Cards/AppCard.swift
+++ b/SupportCompanion/Views/Cards/AppCard.swift
@@ -8,41 +8,21 @@
import Foundation
import SwiftUI
+// Mode-specific details (version, icon, button label) are resolved by ApplicationsInfoManager when the
+// list is built, so this view only displays them.
struct AppCard: View {
let card: InstalledApp
- let version: String
-
- init(card: InstalledApp) {
- self.card = card
+
+ @State private var resolvedTitleImage: String = "app.gift.fill"
- // Determine version
- if AppStateManager.shared.preferences.mode == Constants.modes.intune,
- let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: card.bundleId) {
- let appInfoPlistPath = "\(appURL.path)/Contents/Info.plist"
- self.version = getAppVersion(plistPath: appInfoPlistPath) ?? "Unknown"
- } else {
- self.version = card.version
- }
- }
+ private var version: String { card.version }
var titleImage: String {
- if AppStateManager.shared.preferences.mode == Constants.modes.munki {
- let iconPath = "/Library/Managed Installs/icons/\(card.name).png"
- if FileManager.default.fileExists(atPath: iconPath) {
- return iconPath
- } else {
- return "app.gift.fill"
- }
- } else if AppStateManager.shared.preferences.mode == Constants.modes.systemProfiler {
- let appInfoPlistPath = "\(card.path)/Contents/Info.plist"
- return getIconPath(plistPath: appInfoPlistPath, appPath: card.path) ?? "app.gift.fill"
- } else if AppStateManager.shared.preferences.mode == Constants.modes.intune {
- if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: card.bundleId) {
- let appInfoPlistPath = "\(appURL.path)/Contents/Info.plist"
- return getIconPath(plistPath: appInfoPlistPath, appPath: appURL.path) ?? "app.gift.fill"
- }
- }
- return "app.gift.fill"
+ card.iconPath ?? resolvedTitleImage
+ }
+
+ private var buttonText: String {
+ card.actionText ?? ""
}
var body: some View {
@@ -52,12 +32,14 @@ struct AppCard: View {
imageSize: (40, 40),
content: {
VStack(alignment: .leading, spacing: 5) {
- HStack(alignment: .top) {
- Text("\(Constants.TabelHeaders.version):")
- .bold()
- Text(version)
+ if !version.isEmpty {
+ HStack(alignment: .top) {
+ Text("\(Constants.TableHeaders.version):")
+ .bold()
+ Text(version)
+ }
+ .font(.system(size: 14))
}
- .font(.system(size: 14))
if !card.arch.isEmpty {
HStack {
@@ -76,23 +58,53 @@ struct AppCard: View {
}
.font(.system(size: 14))
}
-
- if card.isSelfServe {
- ScButton(Constants.General.manage, action: {
- Task {
+
+ HStack {
+ if card.isSelfServe {
+ ScButton(buttonText, action: {
if !card.action.isEmpty {
- _ = try await ExecutionService.executeShellCommand(card.action)
+ do {
+ _ = try await ExecutionService.executeShellCommand(card.action)
+ } catch {
+ Logger.shared.logError("App card action '\(card.action)' failed: \(error)")
+ }
}
+ })
+ .padding(.top, 40)
+ }
+ if AppStateManager.shared.preferences.mode == Constants.Modes.jamf {
+ if let pending = AppStateManager.shared.pendingJamfUpdates.first(where: { $0.policyName == card.name }),
+ let patchID = pending.patchId {
+ let isRunning = AppStateManager.shared.pendingJamfUpdatesManager.isRunning(patchId: patchID)
+ ScButton(isRunning ? "Updating…" : "Update", action: {
+ await AppStateManager.shared.pendingJamfUpdatesManager.runPatch(patchId: patchID)
+ })
+ .disabled(isRunning)
+ .padding(.top, 40)
}
- })
- .frame(maxWidth: .infinity, alignment: .center)
- .padding(.top, 40)
+ }
}
+ .frame(maxWidth: .infinity, alignment: .center)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
)
+ .task {
+ await loadRemoteIconIfNeeded()
+ }
+ }
+
+ @MainActor
+ private func loadRemoteIconIfNeeded() async {
+ guard card.iconPath == nil, let iconUrl = card.iconUrl, !iconUrl.isEmpty else { return }
+ // Attempt to download icon asynchronously
+ if let iconPath = try? await downloadAppIcon(forApp: card) {
+ resolvedTitleImage = iconPath
+ } else {
+ // Keep fallback if download fails
+ resolvedTitleImage = "app.gift.fill"
+ }
}
}
@@ -141,3 +153,4 @@ struct PlistService {
return nil
}
}
+
diff --git a/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift b/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift
index e4c2349..c7d378c 100644
--- a/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift
+++ b/SupportCompanion/Views/Cards/BatteryEvergreenStack.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct BatteryEvergreenStack: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
var body: some View {
if !viewModel.isCardVisible(Constants.Cards.evergreen) && !viewModel.isCardVisible(Constants.Cards.battery) {
@@ -31,7 +31,7 @@ struct BatteryEvergreenStack: View {
.fixedSize(horizontal: false, vertical: false)
}
- if viewModel.isCardVisible(Constants.Cards.evergreen) && appState.preferences.mode == Constants.modes.munki {
+ if viewModel.isCardVisible(Constants.Cards.evergreen) && appState.preferences.mode == Constants.Modes.munki {
ScCard(title: "\(Constants.CardTitle.evergreen)", titleImageName: "leaf.fill", content: {
VStack(alignment: .leading) {
Text("Rings")
@@ -54,6 +54,24 @@ struct BatteryEvergreenStack: View {
})
.fixedSize(horizontal: false, vertical: false)
}
+
+ if viewModel.isCardVisible(Constants.Cards.jamfInfo) && appState.preferences.mode == Constants.Modes.jamf {
+ ScCard(title: Constants.CardTitle.jamfInfo, titleImageName: "server.rack", content: {
+ VStack(alignment: .leading, spacing: 5) {
+ CardData(info: appState.jamfInfoManager.jamfInfo.toKeyValuePairs())
+ Spacer()
+ }
+ .padding(.horizontal)
+ //.frame(height: 116)
+ .frame(maxHeight: .infinity, alignment: .top)
+ .frame(minHeight: 110)
+ })
+ .fixedSize(horizontal: false, vertical: false)
+ }
+
+ if viewModel.isCardVisible(Constants.Cards.fleetInfo) && appState.preferences.mode == Constants.Modes.fleet {
+ FleetInfoCard()
+ }
}
}
}
diff --git a/SupportCompanion/Views/Cards/DeviceInformationCard.swift b/SupportCompanion/Views/Cards/DeviceInformationCard.swift
index 4f920f1..4a7e210 100644
--- a/SupportCompanion/Views/Cards/DeviceInformationCard.swift
+++ b/SupportCompanion/Views/Cards/DeviceInformationCard.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct DeviceInformationCard: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -75,7 +75,7 @@ struct DeviceInfoSection: View {
VStack(alignment: .leading, spacing: 5) {
ForEach(group.1, id: \.key) { item in
- if item.key != "lastRestartDays" {
+ if item.key != Constants.DeviceInfo.Keys.lastRestartDays {
if item.key == Constants.DeviceInfo.Keys.lastRestart {
LastRestartRow(
label: item.display,
@@ -165,11 +165,12 @@ struct LastRestartRow: View {
HStack(spacing: 5) {
Text(formattedLastRestart)
.foregroundColor(color)
- Image(systemName: "clock.fill")
- .foregroundColor(color)
+ //Image(systemName: "clock.fill")
+ // .foregroundColor(color)
+ InfoHelp(text: Constants.ToolTips.deviceLastRebooted, icon: "clock.fill", color: color)
}
.font(.system(size: 14))
- .help(Constants.ToolTips.deviceLastRebooted)
+ //.help(Constants.ToolTips.deviceLastRebooted)
}
}
diff --git a/SupportCompanion/Views/Cards/EleveationCard.swift b/SupportCompanion/Views/Cards/ElevationCard.swift
similarity index 78%
rename from SupportCompanion/Views/Cards/EleveationCard.swift
rename to SupportCompanion/Views/Cards/ElevationCard.swift
index 2c73880..e006b5b 100644
--- a/SupportCompanion/Views/Cards/EleveationCard.swift
+++ b/SupportCompanion/Views/Cards/ElevationCard.swift
@@ -9,12 +9,12 @@ import Foundation
import SwiftUI
struct ElevationCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@State private var showReasonInput = false
@State private var elevationReason = ""
var body: some View {
- let elevationManager = ElevationManager(appState: appState)
+ let elevationManager = ElevationManager.shared
VStack(alignment: .leading) {
ScCard(title: Constants.CardTitle.privileges, titleImageName: "lock.fill", useMultiColor: false, content: {
@@ -34,17 +34,17 @@ struct ElevationCard: View {
HStack {
ScButton(Constants.General.elevate, disabled: appState.userInfoManager.userInfo.isAdmin || appState.isDemotionActive) {
- if appState.preferences.requireReasonForElevation {
+ if await appState.preferences.elevation.requireReasonForElevation {
showReasonInput = true // Show reason input modal
} else {
- elevationManager.handleElevation(reason: "")
+ await elevationManager.handleElevation(reason: "")
}
}
.padding(.top)
ScButton(Constants.General.demote, disabled: !appState.isDemotionActive) {
- appState.stopDemotionTimer()
- elevationManager.demotePrivileges(completion: { success in
+ await appState.stopDemotionTimer()
+ await elevationManager.demotePrivileges(completion: { success in
if success {
Logger.shared.logDebug("Successfully demoted privileges")
} else {
@@ -60,7 +60,9 @@ struct ElevationCard: View {
})
}
.sheet(isPresented: $showReasonInput) {
- ReasonInputView(isPresented: $showReasonInput, onElevate: elevationManager.handleElevation)
+ ReasonInputView(isPresented: $showReasonInput) { reason in
+ elevationManager.handleElevation(reason: reason)
+ }
}
}
}
diff --git a/SupportCompanion/Views/Cards/FleetInfoCard.swift b/SupportCompanion/Views/Cards/FleetInfoCard.swift
new file mode 100644
index 0000000..3dd36e9
--- /dev/null
+++ b/SupportCompanion/Views/Cards/FleetInfoCard.swift
@@ -0,0 +1,41 @@
+//
+// FleetInfoCard.swift
+// SupportCompanion
+//
+// This Mac's record in Fleet, shown under Battery like the Jamf card. Hide with HiddenCards `Fleet`.
+//
+
+import SwiftUI
+
+struct FleetInfoCard: View {
+ @Environment(AppStateManager.self) private var appState
+
+ var body: some View {
+ ScCard(
+ title: Constants.CardTitle.fleetInfo, titleImageName: "server.rack",
+ content: {
+ VStack(alignment: .leading, spacing: 5) {
+ // Fleet withholds the host record until sign-in, so the rows would all read Unknown.
+ if appState.fleetDeviceManager.isSignedOut {
+ Text(Constants.Fleet.signedOutRecord)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ ScSmallButton(Constants.Fleet.signIn) {
+ appState.fleetSSOController.present()
+ }
+ .padding(.top, 2)
+ } else {
+ CardData(info: appState.fleetDeviceManager.infoRows)
+ }
+ Spacer()
+ }
+ .padding(.horizontal)
+ .frame(maxHeight: .infinity, alignment: .top)
+ .frame(minHeight: 110)
+ }
+ )
+ .fixedSize(horizontal: false, vertical: false)
+ .task { await appState.fleetDeviceManager.refreshIfStale() }
+ }
+}
diff --git a/SupportCompanion/Views/Cards/KSSOCard.swift b/SupportCompanion/Views/Cards/KSSOCard.swift
index c0cf034..b7f5465 100644
--- a/SupportCompanion/Views/Cards/KSSOCard.swift
+++ b/SupportCompanion/Views/Cards/KSSOCard.swift
@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
struct KSSOCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
VStack(alignment: .leading){
diff --git a/SupportCompanion/Views/Cards/PSSOCard.swift b/SupportCompanion/Views/Cards/PSSOCard.swift
index 7fb5e1f..0b21b01 100644
--- a/SupportCompanion/Views/Cards/PSSOCard.swift
+++ b/SupportCompanion/Views/Cards/PSSOCard.swift
@@ -10,7 +10,7 @@ import Foundation
import SwiftUI
struct PSSOCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
VStack(alignment: .leading){
diff --git a/SupportCompanion/Views/Cards/PatchingProgressCard.swift b/SupportCompanion/Views/Cards/PatchingProgressCard.swift
index 847a31e..c81245b 100644
--- a/SupportCompanion/Views/Cards/PatchingProgressCard.swift
+++ b/SupportCompanion/Views/Cards/PatchingProgressCard.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct PatchingProgressCard: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
var body: some View {
if viewModel.isCardVisible(Constants.Cards.appPatchProgress) {
@@ -20,36 +20,22 @@ struct PatchingProgressCard: View {
CircularProgressWithWave(
progress: appState.installPercentage / 100,
size: 200,
- waveHeight: (appState.installPercentage == 0.0 || appState.installPercentage == 100.0) ? 0 : 5
+ waveHeight: (appState.installPercentage == 0.0 || appState.installPercentage == 100.0) ? 0 : 5,
+ tint: NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? .controlAccentColor
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
})
}
.onAppear {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.startInstallPercentageTask()
- }
- if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.startInstallPercentageTask()
- }
+ appState.activeUpdatesManager?.startInstallPercentageTask()
}
.onDisappear {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.stopInstallPercentageTask()
- }
- if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.stopInstallPercentageTask()
- }
+ appState.activeUpdatesManager?.stopInstallPercentageTask()
}
.onChange(of: appState.windowIsVisible) { oldValue, newValue in
if !newValue {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.stopInstallPercentageTask()
- }
- if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.stopInstallPercentageTask()
- }
+ appState.activeUpdatesManager?.stopInstallPercentageTask()
}
}
}
diff --git a/SupportCompanion/Views/Cards/PendingUpdatesCard.swift b/SupportCompanion/Views/Cards/PendingUpdatesCard.swift
index 1617849..52c5256 100644
--- a/SupportCompanion/Views/Cards/PendingUpdatesCard.swift
+++ b/SupportCompanion/Views/Cards/PendingUpdatesCard.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct PendingUpdatesCard: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -39,16 +39,16 @@ struct PendingUpdatesCard: View {
private var headerView: some View {
HStack {
- Text(Constants.TabelHeaders.name)
+ Text(Constants.TableHeaders.name)
.font(.subheadline)
.bold()
.frame(maxWidth: .infinity, alignment: .leading)
- Text(Constants.TabelHeaders.version)
+ Text(Constants.TableHeaders.version)
.font(.subheadline)
.bold()
.frame(maxWidth: .infinity, alignment: .trailing)
- if appState.preferences.mode == Constants.modes.intune {
- Text("")
+ if let detailTitle = appState.activeUpdatesManager?.pendingUpdatesDetailColumnTitle {
+ Text(detailTitle)
.font(.subheadline)
.bold()
.frame(maxWidth: .infinity, alignment: .trailing)
@@ -59,36 +59,50 @@ struct PendingUpdatesCard: View {
.background(Color.clear)
}
- @ViewBuilder
private var pendingUpdatesList: some View {
- if appState.preferences.mode == Constants.modes.munki {
- updateList(items: appState.pendingMunkiUpdates)
- } else if appState.preferences.mode == Constants.modes.intune {
- updateList(items: appState.pendingIntuneUpdates)
- }
+ updateList(items: appState.activeUpdatesManager?.pendingUpdates ?? [])
}
- private func updateList(items: [T]) -> some View where T: PendingUpdate {
- List {
- if items.isEmpty {
+ /// Concrete row type: ForEach with a key path over `any PendingUpdate` crashes the Swift compiler.
+ private struct Row: Identifiable {
+ let id: UUID
+ let name: String
+ let version: String
+ let dueBy: String?
+ }
+
+ private func updateList(items: [any PendingUpdate]) -> some View {
+ let rows = items.map { Row(id: $0.id, name: $0.name, version: $0.version, dueBy: $0.dueBy) }
+ return List {
+ if rows.isEmpty {
Text("No pending updates")
.frame(maxWidth: .infinity, alignment: .leading)
} else {
- ForEach(items) { update in
- HStack {
+ ForEach(rows) { update in
+ HStack(spacing: 8) {
+ // Keep this leading text flexible
Text(update.name)
- .frame(maxWidth: .infinity, alignment: .leading)
+ .lineLimit(2)
+ .minimumScaleFactor(0.8)
+ .truncationMode(.tail)
+
+ Spacer()
+
+ // Keep this trailing text compact; no infinite frames
Text(update.version)
- .frame(maxWidth: .infinity, alignment: .trailing)
.foregroundColor(colorScheme == .dark ? .gray : .grayLight)
- if let intuneUpdate = update as? PendingIntuneUpdate, intuneUpdate.showInfoIcon {
- Image(systemName: "info.circle")
- .help(intuneUpdate.pendingReason)
+ .lineLimit(1)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+
+ if let dueBy = update.dueBy {
+ Text(dueBy)
+ .foregroundColor(colorScheme == .dark ? .gray : .grayLight)
+ .lineLimit(1)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
+ .padding(.vertical, 6)
.listRowSeparator(.hidden)
- Divider()
}
}
}
@@ -99,23 +113,11 @@ struct PendingUpdatesCard: View {
}
private func startFetching() {
- DispatchQueue.main.async {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.startFetchingList()
- } else if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.startFetchingList()
- }
- }
+ appState.activeUpdatesManager?.startFetchingList()
}
private func stopFetching() {
- DispatchQueue.main.async {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.stopFetchingList()
- } else if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.stopFetchingList()
- }
- }
+ appState.activeUpdatesManager?.stopFetchingList()
}
private func handleVisibilityChange(_ newValue: Bool) {
@@ -124,10 +126,3 @@ struct PendingUpdatesCard: View {
}
}
}
-
-struct PendingMunkiUpdatesCard_Previews: PreviewProvider {
- static var previews: some View {
- PendingUpdatesCard(viewModel: CardGridViewModel(appState: AppStateManager()))
- .previewLayout(.sizeThatFits)
- }
-}
diff --git a/SupportCompanion/Views/Cards/StorageDeviceManagementStack.swift b/SupportCompanion/Views/Cards/StorageDeviceManagementStack.swift
index a6a1ece..a6fea53 100644
--- a/SupportCompanion/Views/Cards/StorageDeviceManagementStack.swift
+++ b/SupportCompanion/Views/Cards/StorageDeviceManagementStack.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct StorageDeviceManagementStack: View {
- @ObservedObject var viewModel: CardGridViewModel
- @EnvironmentObject var appState: AppStateManager
+ var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -20,11 +20,11 @@ struct StorageDeviceManagementStack: View {
// Storage Card
if viewModel.isCardVisible(Constants.Cards.storage) {
ScCard(title: "\(Constants.CardTitle.storage)",
- titleImageName: "internaldrive.fill",
- buttonImageName: "macwindow.on.rectangle",
- buttonAction: { viewModel.openStoragePanel() },
- buttonHelpText: Constants.ToolTips.openStoragePanel,
- content: {
+ titleImageName: "internaldrive.fill",
+ buttonImageName: "macwindow.on.rectangle",
+ buttonAction: { viewModel.openStoragePanel() },
+ buttonHelpText: Constants.ToolTips.openStoragePanel,
+ content: {
VStack(alignment: .leading, spacing: 5) {
CardData(
info: appState.storageInfoManager.storageInfo.toKeyValuePairs(),
@@ -38,9 +38,7 @@ struct StorageDeviceManagementStack: View {
Text("\(String(format: "%.1f", appState.storageInfoManager.storageInfo.usage))% Used")
.font(.system(size: 14))}
)
- .tint(appState.storageInfoManager.storageInfo.usage < 50 ? .ScGreen
- : appState.storageInfoManager.storageInfo.usage < 80 ? (colorScheme == .light ? .orangeLight : .orange)
- : (colorScheme == .light ? .redLight : .red))
+ .tint(appState.storageInfoManager.storageInfo.usage.storageColor(colorScheme: colorScheme))
.padding(.top)
)
}
diff --git a/SupportCompanion/Views/Cards/UserInfoCard.swift b/SupportCompanion/Views/Cards/UserInfoCard.swift
index 8ed7c87..fec7bfd 100644
--- a/SupportCompanion/Views/Cards/UserInfoCard.swift
+++ b/SupportCompanion/Views/Cards/UserInfoCard.swift
@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
struct UserInfoCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
VStack(alignment: .leading){
diff --git a/SupportCompanion/Views/CircularProgressWave.swift b/SupportCompanion/Views/CircularProgressWave.swift
index 463eb67..95baef3 100644
--- a/SupportCompanion/Views/CircularProgressWave.swift
+++ b/SupportCompanion/Views/CircularProgressWave.swift
@@ -5,82 +5,34 @@
// Created by Tobias Almén on 2024-11-16.
//
+import AppKit
import Foundation
import SwiftUI
-struct WaveShape: Shape {
- var progress: CGFloat
- var waveHeight: CGFloat
- var phase: CGFloat
-
- var animatableData: CGFloat {
- get { phase }
- set { phase = newValue }
- }
-
- func path(in rect: CGRect) -> Path {
- var path = Path()
- let width = rect.width
- let height = rect.height
- let midHeight = height * (1 - progress)
-
- path.move(to: CGPoint(x: 0, y: midHeight))
-
- for x in stride(from: 0, to: width, by: 2) {
- let relativeX = x / width
- let sine = sin((relativeX + phase) * 2 * .pi)
- let y = midHeight + waveHeight * sine
- path.addLine(to: CGPoint(x: x, y: y))
- }
-
- path.addLine(to: CGPoint(x: width, y: height))
- path.addLine(to: CGPoint(x: 0, y: height))
- path.closeSubpath()
-
- return path
- }
-}
-
struct CircularProgressWithWave: View {
- @State private var phase: CGFloat = 0
var progress: CGFloat
var size: CGFloat
var waveHeight: CGFloat
- var gradient: Gradient = Gradient(colors: [.blue, .purple])
- @Environment(\.colorScheme) var colorScheme // Access system light/dark mode
- @EnvironmentObject var appState: AppStateManager
- @State private var isAnimating = false // Track animation state
+ var tint: NSColor
+ @Environment(\.colorScheme) var colorScheme
+ @Environment(\.accessibilityReduceMotion) var reduceMotion
var body: some View {
ZStack {
// Accent ring
Circle()
.stroke(
- colorScheme == .dark
- ? Color(nsColor: .gray).opacity(0.5)
- : Color(nsColor: .gray).opacity(0.3),
+ colorScheme == .dark
+ ? Color(nsColor: .gray).opacity(0.5)
+ : Color(nsColor: .gray).opacity(0.3),
lineWidth: size * 0.02
)// Thinner ring
.frame(width: size, height: size)
- // Wave shape masked to a circle
- WaveShape(progress: progress, waveHeight: waveHeight, phase: phase)
- .fill(LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom))
- .frame(width: size, height: size) // Matches the full size of the ring
+ // Wave masked to a circle
+ WaveLayerView(progress: progress, waveHeight: waveHeight, isMoving: !reduceMotion, tint: tint)
+ .frame(width: size, height: size)
.clipShape(Circle())
- .onAppear {
- startAnimation()
- }
- .onDisappear {
- stopAnimation()
- }
- .onChange(of: appState.windowIsVisible) { oldValue, newValue in
- if newValue {
- startAnimation()
- } else {
- stopAnimation()
- }
- }
// Progress text in the center
Text("\(Int(progress * 100))%")
@@ -90,17 +42,145 @@ struct CircularProgressWithWave: View {
}
.frame(width: size, height: size)
}
+}
+
+/// Renders the wave with Core Animation instead of SwiftUI.
+///
+/// Any SwiftUI animation, even one that only moves an offset, updates the view graph in the app on
+/// every frame; for this wave that cost ~35% CPU for as long as the Home page was open. A CAAnimation
+/// is played by the render server, so the app does no per-frame work. The earlier `.drawingGroup()`
+/// version also allocated ~140 MB of Metal buffers.
+private struct WaveLayerView: NSViewRepresentable {
+ var progress: CGFloat
+ var waveHeight: CGFloat
+ var isMoving: Bool
+ var tint: NSColor
+
+ func makeNSView(context: Context) -> WaveNSView {
+ WaveNSView()
+ }
- private func startAnimation() {
- guard !isAnimating else { return } // Prevent duplicate animations
- isAnimating = true
- withAnimation(Animation.linear(duration: 4).repeatForever(autoreverses: false)) {
- phase = 1
+ func updateNSView(_ view: WaveNSView, context: Context) {
+ view.progress = progress
+ view.waveHeight = waveHeight
+ view.isMoving = isMoving
+ view.tint = tint
+ }
+}
+
+private final class WaveNSView: NSView {
+ var progress: CGFloat = 0 { didSet { if progress != oldValue { needsLayout = true } } }
+ var waveHeight: CGFloat = 0 { didSet { if waveHeight != oldValue { needsLayout = true } } }
+ var isMoving = false { didSet { if isMoving != oldValue { updateAnimation() } } }
+ var tint: NSColor = .controlAccentColor { didSet { if tint != oldValue { updateColors() } } }
+
+ /// Seconds for the wave to travel one full period
+ private let wavePeriod: CFTimeInterval = 4
+ private let animationKey = "waveMotion"
+ private let gradientLayer = CAGradientLayer()
+ private let waveMask = CAShapeLayer()
+ /// Width the running animation was built for; the slide distance depends on it
+ private var animatedWidth: CGFloat?
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ layer?.addSublayer(gradientLayer)
+ gradientLayer.mask = waveMask
+ // Top to bottom, matching the previous SwiftUI LinearGradient (layer y points up on macOS)
+ gradientLayer.startPoint = CGPoint(x: 0.5, y: 1)
+ gradientLayer.endPoint = CGPoint(x: 0.5, y: 0)
+ updateColors()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func layout() {
+ super.layout()
+ CATransaction.begin()
+ CATransaction.setDisableActions(true)
+ gradientLayer.frame = bounds
+ // The mask is two periods wide; sliding it left by one period loops seamlessly
+ waveMask.bounds = CGRect(x: 0, y: 0, width: bounds.width * 2, height: bounds.height)
+ waveMask.anchorPoint = .zero
+ waveMask.position = .zero
+ waveMask.path = wavePath(width: bounds.width * 2, height: bounds.height)
+ CATransaction.commit()
+ updateAnimation()
+ }
+
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ updateColors()
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ updateAnimation()
+ }
+
+ /// Top stop is a lighter shade of the tint, so the wave keeps its sense of depth on any accent.
+ private func updateColors() {
+ effectiveAppearance.performAsCurrentDrawingAppearance {
+ guard let base = tint.usingColorSpace(.sRGB) else {
+ gradientLayer.colors = [tint.cgColor, tint.cgColor]
+ return
+ }
+ var hue: CGFloat = 0
+ var saturation: CGFloat = 0
+ var brightness: CGFloat = 0
+ var alpha: CGFloat = 0
+ base.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
+ let top = NSColor(
+ hue: hue,
+ saturation: max(saturation - 0.25, 0),
+ brightness: min(brightness + 0.2, 1),
+ alpha: alpha
+ )
+ gradientLayer.colors = [top.cgColor, base.cgColor]
}
}
- private func stopAnimation() {
- isAnimating = false
- phase = 0 // Reset phase to avoid lingering animation effects
+ private func updateAnimation() {
+ let shouldAnimate = isMoving && window != nil && bounds.width > 0
+ let isAnimating = waveMask.animation(forKey: animationKey) != nil
+ // Leave a running animation alone unless the width changed, so layout passes don't restart it
+ if shouldAnimate && isAnimating && animatedWidth == bounds.width { return }
+
+ waveMask.removeAnimation(forKey: animationKey)
+ animatedWidth = nil
+ guard shouldAnimate else { return }
+ animatedWidth = bounds.width
+
+ let animation = CABasicAnimation(keyPath: "position.x")
+ animation.fromValue = 0
+ animation.toValue = -bounds.width
+ animation.duration = wavePeriod
+ animation.repeatCount = .infinity
+ animation.timingFunction = CAMediaTimingFunction(name: .linear)
+ animation.isRemovedOnCompletion = false
+ waveMask.add(animation, forKey: animationKey)
+ }
+
+ /// Filled area below a sine wave with two periods across `width`, at `progress` of the height.
+ private func wavePath(width: CGFloat, height: CGFloat) -> CGPath {
+ let path = CGMutablePath()
+ let level = height * progress
+ let periodWidth = width / 2
+ let step: CGFloat = 2
+
+ path.move(to: CGPoint(x: 0, y: 0))
+ var x: CGFloat = 0
+ while x <= width {
+ let y = level + waveHeight * sin(x / periodWidth * 2 * .pi)
+ path.addLine(to: CGPoint(x: x, y: y))
+ x += step
+ }
+ path.addLine(to: CGPoint(x: width, y: level + waveHeight * sin(2 * 2 * .pi)))
+ path.addLine(to: CGPoint(x: width, y: 0))
+ path.closeSubpath()
+ return path
}
}
diff --git a/SupportCompanion/Views/CompactCards/CompactBatteryCard.swift b/SupportCompanion/Views/CompactCards/CompactBatteryCard.swift
index 987623a..e47aaa3 100644
--- a/SupportCompanion/Views/CompactCards/CompactBatteryCard.swift
+++ b/SupportCompanion/Views/CompactCards/CompactBatteryCard.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct CompactBatteryCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
ScCardCompact(
diff --git a/SupportCompanion/Views/CompactCards/CompactDeviceCard.swift b/SupportCompanion/Views/CompactCards/CompactDeviceCard.swift
index 0c1933a..3026783 100644
--- a/SupportCompanion/Views/CompactCards/CompactDeviceCard.swift
+++ b/SupportCompanion/Views/CompactCards/CompactDeviceCard.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct CompactDeviceCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
ScCardCompact(
diff --git a/SupportCompanion/Views/CompactCards/CompactElevationCard.swift b/SupportCompanion/Views/CompactCards/CompactElevationCard.swift
index 78e7ae1..7ef91c5 100644
--- a/SupportCompanion/Views/CompactCards/CompactElevationCard.swift
+++ b/SupportCompanion/Views/CompactCards/CompactElevationCard.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct CompactElevationCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@State private var showReasonInput = false
var body: some View {
@@ -26,8 +26,11 @@ struct CompactElevationCard: View {
Spacer()
HStack {
- Button(action: {
- if appState.preferences.requireReasonForElevation {
+ ScSmallButton(
+ Constants.General.elevate,
+ disabled: appState.userInfoManager.userInfo.isAdmin || appState.isDemotionActive
+ ) {
+ if appState.preferences.elevation.requireReasonForElevation {
ReasonInputManager.shared.presentAsWindow(
isPresented: $showReasonInput,
onElevate: { reason in
@@ -37,19 +40,9 @@ struct CompactElevationCard: View {
} else {
ElevationManager.shared.handleElevation(reason: "")
}
- }) {
- VStack {
- ButtonTitle(title: Constants.General.elevate, fontSize: 12, isLoading: false)
- }
- .padding(8)
- .background(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor))
- .foregroundColor(.white)
- .cornerRadius(8)
}
- .buttonStyle(PlainButtonStyle())
- .disabled(appState.userInfoManager.userInfo.isAdmin || appState.isDemotionActive)
-
- Button(action: {
+
+ ScSmallButton(Constants.General.demote, disabled: !appState.isDemotionActive) {
appState.stopDemotionTimer()
ElevationManager.shared.demotePrivileges { success in
if success {
@@ -58,17 +51,7 @@ struct CompactElevationCard: View {
Logger.shared.logError("Failed to demote privileges")
}
}
- }) {
- VStack {
- ButtonTitle(title: Constants.General.demote, fontSize: 12, isLoading: false)
- }
- .padding(8)
- .background(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor))
- .foregroundColor(.white)
- .cornerRadius(8)
}
- .buttonStyle(PlainButtonStyle())
- .disabled(!appState.isDemotionActive)
}
}
}
diff --git a/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift b/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift
new file mode 100644
index 0000000..79f13c0
--- /dev/null
+++ b/SupportCompanion/Views/CompactCards/CompactFleetInfoCard.swift
@@ -0,0 +1,36 @@
+//
+// CompactFleetInfoCard.swift
+// SupportCompanion
+//
+
+import SwiftUI
+
+struct CompactFleetInfoCard: View {
+ @Environment(AppStateManager.self) var appState
+
+ var body: some View {
+ ScCardCompact(
+ title: Constants.CardTitle.fleetInfo,
+ titleImageName: "server.rack",
+ imageSize: (13, 13),
+ content: {
+ if appState.fleetDeviceManager.isSignedOut {
+ VStack(alignment: .leading, spacing: 6) {
+ Text(Constants.Fleet.signedOutRecord)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ ScSmallButton(Constants.Fleet.signIn) {
+ // Not present() directly: the sheet belongs to the main window, which may
+ // not be open from here. This opens it first, then signs in.
+ ActionHelpers.openManagementApp(appURL: "supportcompanion://fleetsignin")
+ }
+ }
+ } else {
+ CardData(info: appState.fleetDeviceManager.infoRows, fontSize: 12)
+ }
+ }
+ )
+ .task { await appState.fleetDeviceManager.refreshIfStale() }
+ }
+}
diff --git a/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift b/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift
new file mode 100644
index 0000000..c13a10e
--- /dev/null
+++ b/SupportCompanion/Views/CompactCards/CompactFleetPoliciesCard.swift
@@ -0,0 +1,77 @@
+//
+// CompactFleetPoliciesCard.swift
+// SupportCompanion
+//
+
+import SwiftUI
+
+struct CompactFleetPoliciesCard: View {
+ @Environment(AppStateManager.self) var appState
+ @Environment(\.colorScheme) var colorScheme
+
+ private var manager: FleetDeviceManager { appState.fleetDeviceManager }
+
+ var body: some View {
+ ScCardCompact(
+ title: Constants.CardTitle.fleetPolicies,
+ titleImageName: "checkmark.shield.fill",
+ imageSize: (13, 13),
+ content: {
+ summary
+ .font(.system(size: 12))
+ }
+ )
+ .task { await manager.refreshIfStale() }
+ }
+
+ @ViewBuilder
+ private var summary: some View {
+ let failing = manager.failingPolicies
+ let checked = manager.checkedPolicies
+ VStack(alignment: .leading, spacing: 8) {
+ if manager.host == nil {
+ // Signed out, Fleet still gives a failing count through the ungated summary, so the
+ // card can say what's actually wrong instead of asking the user to sign in blind
+ if let failingCount = manager.failingChecksCount {
+ if failingCount > 0 {
+ Label(String(format: Constants.Fleet.signedOutFailing, failingCount),
+ systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(colorScheme == .light ? Color.orangeLight : .orange)
+ } else {
+ Label(Constants.Fleet.signedOutPassing, systemImage: "checkmark.circle.fill")
+ .foregroundStyle(Color.ScGreen)
+ }
+ } else {
+ Text(manager.isSignedOut ? Constants.Fleet.signInForChecks : Constants.FleetInfo.unknown)
+ .foregroundStyle(.secondary)
+ }
+ } else if checked.isEmpty {
+ Text(Constants.Fleet.noPolicies)
+ .foregroundStyle(.secondary)
+ } else if failing.isEmpty {
+ Label(String(format: Constants.Fleet.policiesPassing, checked.count), systemImage: "checkmark.circle.fill")
+ .foregroundStyle(Color.ScGreen)
+ } else {
+ Label(String(format: Constants.Fleet.policiesFailing, failing.count, checked.count), systemImage: "exclamationmark.triangle.fill")
+ .foregroundStyle(colorScheme == .light ? Color.orangeLight : .orange)
+ }
+
+ if manager.isSignedOut {
+ ScSmallButton(Constants.Fleet.signIn) {
+ // Not present() directly: the sheet belongs to the main window, which may not be
+ // open from here. This opens it first, then signs in.
+ ActionHelpers.openManagementApp(appURL: "supportcompanion://fleetsignin")
+ }
+ }
+
+ if manager.host != nil {
+ HStack {
+ FleetRecheckButton(compact: true)
+ ScSmallButton(Constants.Fleet.complianceDetails) {
+ ActionHelpers.openManagementApp(appURL: "supportcompanion://compliance")
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift b/SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift
new file mode 100644
index 0000000..8252d34
--- /dev/null
+++ b/SupportCompanion/Views/CompactCards/CompactJamfInfoCard.swift
@@ -0,0 +1,24 @@
+//
+// CompactJamfInfoCard.swift
+// SupportCompanion
+//
+// Created by Tobias Almén on 2025-11-12.
+//
+
+import Foundation
+import SwiftUI
+
+struct CompactJamfInfoCard: View {
+ @Environment(AppStateManager.self) var appState
+
+ var body: some View {
+ ScCardCompact(
+ title: "Jamf",
+ titleImageName: "server.rack",
+ imageSize: (13, 13),
+ content: {
+ CardData(info: appState.jamfInfoManager.jamfInfo.toKeyValuePairs(), fontSize: 12)
+ }
+ )
+ }
+}
diff --git a/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift b/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift
index 5ddef38..89cf239 100644
--- a/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift
+++ b/SupportCompanion/Views/CompactCards/CompactPatchProgressCard.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct CompactPatchProgressCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -16,8 +16,9 @@ struct CompactPatchProgressCard: View {
value: appState.installPercentage,
total: 100,
label: {
- Text("\(String(format: "%.1f", appState.installPercentage))% Patched")
- .font(.system(size: 12))}
+ Text("\(String(format: "%1d", Int(appState.installPercentage/100*100)))% Patched")
+ .font(.system(size: 12))
+ }
)
.tint(appState.installPercentage < 90 ? (colorScheme == .light ? .orangeLight : .orange)
: appState.installPercentage < 60 ? (colorScheme == .light ? .redLight : .red)
@@ -26,20 +27,10 @@ struct CompactPatchProgressCard: View {
}
)
.onAppear {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.startInstallPercentageTask()
- }
- if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.startInstallPercentageTask()
- }
+ appState.activeUpdatesManager?.startInstallPercentageTask()
}
.onDisappear() {
- if appState.preferences.mode == Constants.modes.munki {
- appState.pendingMunkiUpdatesManager.stopInstallPercentageTask()
- }
- if appState.preferences.mode == Constants.modes.intune {
- appState.pendingIntuneUpdatesManager.stopInstallPercentageTask()
- }
+ appState.activeUpdatesManager?.stopInstallPercentageTask()
}
}
}
diff --git a/SupportCompanion/Views/CompactCards/CompactStorageCard.swift b/SupportCompanion/Views/CompactCards/CompactStorageCard.swift
index dae299a..9da8b33 100644
--- a/SupportCompanion/Views/CompactCards/CompactStorageCard.swift
+++ b/SupportCompanion/Views/CompactCards/CompactStorageCard.swift
@@ -2,7 +2,7 @@ import Foundation
import SwiftUI
struct CompactStorageCard: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@Environment(\.colorScheme) var colorScheme
var body: some View {
@@ -22,9 +22,7 @@ struct CompactStorageCard: View {
Text("\(String(format: "%.1f", appState.storageInfoManager.storageInfo.usage))% Used")
.font(.system(size: 12))}
)
- .tint(appState.storageInfoManager.storageInfo.usage < 50 ? .ScGreen
- : appState.storageInfoManager.storageInfo.usage < 80 ? (colorScheme == .light ? .orangeLight : .orange)
- : (colorScheme == .light ? .redLight : .red))
+ .tint(appState.storageInfoManager.storageInfo.usage.storageColor(colorScheme: colorScheme))
)
}
}
diff --git a/SupportCompanion/Views/CustomCardsView.swift b/SupportCompanion/Views/CustomCardsView.swift
index dca17dc..d7f6282 100644
--- a/SupportCompanion/Views/CustomCardsView.swift
+++ b/SupportCompanion/Views/CustomCardsView.swift
@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
struct CustomCardsView: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
let columns = [GridItem(.adaptive(minimum: 300))]
diff --git a/SupportCompanion/Views/DarkLightModeToggle.swift b/SupportCompanion/Views/DarkLightModeToggle.swift
index e40c32d..5a4ce82 100644
--- a/SupportCompanion/Views/DarkLightModeToggle.swift
+++ b/SupportCompanion/Views/DarkLightModeToggle.swift
@@ -1,3 +1,4 @@
+import AppKit
//
// DarkLightModeButton.swift
// SupportCompanion
@@ -6,22 +7,25 @@
//
//NSAppearance.currentDrawing().bestMatch(from: [.aqua, .darkAqua])
import SwiftUI
-import AppKit
struct DarkLightModeToggle: View {
- @AppStorage("isDarkMode") private var isDarkMode: Int = -1 // -1: System mode, 1: Dark, 0: Light
- @Environment(\.colorScheme) var colorScheme // Detect system theme
- @State private var currentSystemTheme: ColorScheme = .light // Track the current system theme
+ @AppStorage("isDarkMode") private var isDarkMode: Int = -1 // -1: System mode, 1: Dark, 0: Light
+ @Environment(\.colorScheme) var colorScheme // Detect system theme
+ @State private var currentSystemTheme: ColorScheme = .light // Track the current system theme
var body: some View {
ZStack {
// Background of the toggle
Capsule()
- .fill(LinearGradient(
- gradient: Gradient(colors: resolvedTheme == .dark ? [.purple, .black] : [.yellow, .orange]),
- startPoint: .leading,
- endPoint: .trailing
- ))
+ .fill(
+ LinearGradient(
+ gradient: Gradient(
+ colors: resolvedTheme == .dark
+ ? [.accentColor, .black] : [.yellow, .orange]),
+ startPoint: .leading,
+ endPoint: .trailing
+ )
+ )
.frame(width: 50, height: 30)
.shadow(color: .black.opacity(0.2), radius: 5, x: 0, y: 2)
.overlay(
@@ -48,14 +52,15 @@ struct DarkLightModeToggle: View {
.frame(width: 50, height: 30)
.onAppear {
updateAppAppearance()
- currentSystemTheme = colorScheme // Initialize system theme
+ currentSystemTheme = colorScheme // Initialize system theme
}
.onChange(of: colorScheme) {
- currentSystemTheme = colorScheme // Update system theme on change
+ currentSystemTheme = colorScheme // Update system theme on change
if isDarkMode == -1 {
updateAppAppearance()
}
- } }
+ }
+ }
// MARK: - Resolved Theme
private var resolvedTheme: ColorScheme {
@@ -77,9 +82,9 @@ struct DarkLightModeToggle: View {
private var iconColor: Color {
switch isDarkMode {
- case 1: return .purple
+ case 1: return .accentColor
case 0: return .yellow
- default: return currentSystemTheme == .dark ? .purple : .yellow
+ default: return currentSystemTheme == .dark ? .accentColor : .yellow
}
}
@@ -114,7 +119,7 @@ struct DarkLightModeToggle: View {
case 0:
NSApp.appearance = NSAppearance(named: .aqua)
default:
- NSApp.appearance = nil // Follow system
+ NSApp.appearance = nil // Follow system
}
}
}
diff --git a/SupportCompanion/Views/Fleet/FleetAppCard.swift b/SupportCompanion/Views/Fleet/FleetAppCard.swift
new file mode 100644
index 0000000..2914db0
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetAppCard.swift
@@ -0,0 +1,307 @@
+//
+// FleetAppCard.swift
+// SupportCompanion
+//
+
+import SwiftUI
+
+struct FleetAppCard: View {
+ let title: FleetSoftwareTitle
+ /// Outlines the card, for apps IT recommends.
+ var highlighted = false
+
+ @State private var icon: NSImage?
+ @State private var confirmingUninstall = false
+ @State private var showingDetails = false
+ @Environment(\.colorScheme) private var colorScheme
+ @Environment(AppStateManager.self) private var appState
+
+ private var manager: FleetSoftwareManager { appState.fleetSoftwareManager }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack(alignment: .center, spacing: 12) {
+ iconView
+ VStack(alignment: .leading, spacing: 3) {
+ Text(title.title)
+ .font(.system(size: 16, weight: .semibold))
+ .lineLimit(2)
+ statusBadge
+ if let installed = installedVersion {
+ versionRow(Constants.Fleet.version, installed)
+ }
+ if let available = title.availableVersion, available != installedVersion {
+ versionRow(Constants.Fleet.latestVersion, available)
+ }
+ }
+ .font(.system(size: 13))
+ Spacer(minLength: 0)
+ }
+
+ actions
+ }
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .isGlass()
+ .cornerRadius(10)
+ .overlay {
+ if highlighted {
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(accentColor.opacity(0.7), lineWidth: 1.5)
+ }
+ }
+ .shadow(radius: 4)
+ .padding(5)
+ // Also look again once the app is installed, for the icon of the app on disk
+ .task(id: "\(title.iconUrl ?? "")|\(title.isInstalled)") {
+ icon = FleetIconCache.shared.cachedIcon(for: title)
+ icon = await FleetIconCache.shared.icon(for: title) ?? icon
+ }
+ .confirmationDialog(
+ String(format: Constants.Fleet.uninstallConfirmTitle, title.title),
+ isPresented: $confirmingUninstall
+ ) {
+ Button(label(for: .uninstall), role: .destructive) {
+ Task { await manager.perform(.uninstall, on: title) }
+ }
+ Button(Constants.Fleet.cancel, role: .cancel) {}
+ } message: {
+ Text(Constants.Fleet.uninstallConfirmMessage)
+ }
+ .sheet(isPresented: $showingDetails) {
+ FleetInstallDetailsSheet(title: title)
+ }
+ }
+
+ // MARK: - Actions
+
+ @ViewBuilder
+ private var actions: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ if manager.isBusy(title) {
+ ProgressView()
+ .controlSize(.small)
+ Text(busyText)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ } else if waitingForAppToClose && appIsRunning {
+ FleetActionButton(
+ manager.retryAction(for: title) == .update ? Constants.Fleet.quitAndUpdate : Constants.Fleet.quitAndInstall,
+ tint: accentColor
+ ) {
+ await manager.quitAndRetry(title)
+ }
+ } else if let action = buttonAction {
+ FleetActionButton(label(for: action), tint: accentColor) {
+ await manager.perform(action, on: title)
+ }
+ }
+ if title.hasFailed && !manager.isBusy(title) {
+ Button(Constants.Fleet.details) { showingDetails = true }
+ .buttonStyle(.link)
+ }
+ Spacer(minLength: 0)
+ if (canReinstall || title.canUninstall) && !manager.isBusy(title) {
+ Menu {
+ if canReinstall && buttonAction != .reinstall {
+ Button(label(for: .reinstall)) {
+ Task { await manager.perform(.reinstall, on: title) }
+ }
+ }
+ if title.canUninstall {
+ Button(label(for: .uninstall), role: .destructive) {
+ confirmingUninstall = true
+ }
+ }
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ }
+ .menuStyle(.borderlessButton)
+ .menuIndicator(.hidden)
+ .fixedSize()
+ .help(Constants.Fleet.moreActions)
+ }
+ }
+ if waitingForAppToClose && !manager.isBusy(title) && manager.actionErrors[title.id] == nil {
+ Label(
+ String(format: appIsRunning ? Constants.Fleet.appOpenMessage : Constants.Fleet.appClosedMessage, title.title),
+ systemImage: "info.circle"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+ if let error = manager.actionErrors[title.id] {
+ Label(error, systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundStyle(colorScheme == .light ? Color.orangeLight : .orange)
+ .lineLimit(2)
+ }
+ }
+ }
+
+ /// The main button's action: install or update, or reinstall to retry a failed install.
+ private var buttonAction: FleetSoftwareTitle.Action? {
+ guard !manager.isBusy(title), title.installer != nil else { return nil }
+ if manager.hasUpdate(title) { return .update }
+ if !title.isInstalled { return .install }
+ return title.status == .failedInstall ? .reinstall : nil
+ }
+
+ private var waitingForAppToClose: Bool {
+ manager.isWaitingForAppToClose(title)
+ }
+
+ private var appIsRunning: Bool {
+ FleetRunningApps.shared.isRunning(title)
+ }
+
+ private var canReinstall: Bool {
+ title.isInstalled && title.installer != nil && !manager.hasUpdate(title)
+ }
+
+ /// Includes a version Fleet just installed that its inventory doesn't show yet.
+ private var installedVersion: String? {
+ manager.installedVersionsAwaitingInventory[title.id] ?? title.installedVersion
+ }
+
+ private var accentColor: Color {
+ Color(NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? .controlAccentColor)
+ }
+
+ private var busyText: String {
+ if manager.runningActions[title.id] == .uninstall || title.status == .pendingUninstall {
+ return Constants.Fleet.uninstalling
+ }
+ return Constants.Fleet.installing
+ }
+
+ private func label(for action: FleetSoftwareTitle.Action) -> String {
+ FleetButtonLabels.current.label(for: title, action: action)
+ }
+
+ @ViewBuilder
+ private var iconView: some View {
+ if let icon {
+ Image(nsImage: icon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 44, height: 44)
+ } else {
+ FleetPlaceholderIcon(name: title.title)
+ .frame(width: 44, height: 44)
+ }
+ }
+
+ @ViewBuilder
+ private var statusBadge: some View {
+ if let (text, color) = status {
+ Text(text)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 2)
+ .background(color.opacity(0.18), in: Capsule())
+ .foregroundStyle(color)
+ }
+ }
+
+ /// Only states worth calling out: the section the card sits in already says installed or available.
+ private var status: (String, Color)? {
+ if manager.isBusy(title) {
+ return (busyText, .blue)
+ }
+ switch title.status {
+ case .pendingInstall:
+ return (Constants.Fleet.installing, .blue)
+ case .pendingUninstall:
+ return (Constants.Fleet.uninstalling, .blue)
+ case .failedInstall where waitingForAppToClose:
+ return (Constants.Fleet.waitingForAppToClose, colorScheme == .light ? .orangeLight : .orange)
+ case .failedInstall:
+ return (Constants.Fleet.installFailed, colorScheme == .light ? .redLight : .red)
+ case .failedUninstall:
+ return (Constants.Fleet.uninstallFailed, colorScheme == .light ? .redLight : .red)
+ default:
+ if manager.hasUpdate(title) {
+ return (Constants.Fleet.updateAvailable, colorScheme == .light ? .orangeLight : .orange)
+ }
+ return nil
+ }
+ }
+
+ private func versionRow(_ label: String, _ value: String) -> some View {
+ HStack(spacing: 4) {
+ Text(label).foregroundStyle(.secondary)
+ Text(value)
+ }
+ }
+}
+
+/// A tinted capsule rather than the filled `ScButton`: a catalog shows many of these at once, and the apps
+/// should carry more weight than a grid of identical solid buttons.
+private struct FleetActionButton: View {
+ let title: String
+ let tint: Color
+ let action: () async -> Void
+
+ @State private var isHovered = false
+ @State private var isRunning = false
+
+ init(_ title: String, tint: Color, action: @escaping () async -> Void) {
+ self.title = title
+ self.tint = tint
+ self.action = action
+ }
+
+ var body: some View {
+ Button {
+ guard !isRunning else { return }
+ Task {
+ isRunning = true
+ await action()
+ isRunning = false
+ }
+ } label: {
+ Text(title)
+ .font(.system(size: 13, weight: .semibold))
+ .padding(.horizontal, 16)
+ .padding(.vertical, 6)
+ .background(isHovered ? tint : tint.opacity(0.18), in: Capsule())
+ .foregroundStyle(isHovered ? Color.white : tint)
+ }
+ .buttonStyle(.plain)
+ .disabled(isRunning)
+ .onHover { isHovered = $0 }
+ .animation(.easeInOut(duration: 0.15), value: isHovered)
+ }
+}
+
+/// Shown for apps without an icon: the app's initial on a colour picked from its name, so each app keeps its colour.
+struct FleetPlaceholderIcon: View {
+ let name: String
+
+ private static let palette: [Color] = [.blue, .indigo, .purple, .pink, .red, .orange, .teal, .green, .mint, .cyan]
+
+ var body: some View {
+ let color = Self.color(for: name)
+ RoundedRectangle(cornerRadius: 10, style: .continuous)
+ .fill(LinearGradient(colors: [color.opacity(0.95), color.opacity(0.65)], startPoint: .top, endPoint: .bottom))
+ .overlay {
+ Text(initial)
+ .font(.system(size: 22, weight: .semibold, design: .rounded))
+ .foregroundStyle(.white)
+ }
+ .padding(2)
+ }
+
+ private var initial: String {
+ name.first(where: { $0.isLetter || $0.isNumber }).map { String($0).uppercased() } ?? "?"
+ }
+
+ /// Stable across launches, unlike `hashValue`.
+ private static func color(for name: String) -> Color {
+ let sum = name.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0xFFFF }
+ return palette[sum % palette.count]
+ }
+}
diff --git a/SupportCompanion/Views/Fleet/FleetAppsView.swift b/SupportCompanion/Views/Fleet/FleetAppsView.swift
new file mode 100644
index 0000000..2d96468
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetAppsView.swift
@@ -0,0 +1,250 @@
+//
+// FleetAppsView.swift
+// SupportCompanion
+//
+// The Apps page in Fleet mode: Fleet's self-service catalog for this Mac.
+//
+
+import SwiftUI
+
+struct FleetAppsView: View {
+ @Environment(AppStateManager.self) private var appState
+ @State private var searchText = ""
+ @State private var selectedCategory: String?
+ /// Collapsed section ids, separated by commas; remembered per user.
+ @AppStorage("FleetCollapsedSections") private var collapsedSections = ""
+
+ private var manager: FleetSoftwareManager { appState.fleetSoftwareManager }
+ private let columns = [GridItem(.adaptive(minimum: 260), alignment: .top)]
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ header
+ content
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 10)
+ .onAppear { manager.startMonitoring() }
+ .onDisappear { manager.stopMonitoring() }
+ }
+
+ // MARK: - Header
+
+ /// Search and filters share a row when they fit, so the catalog starts higher up.
+ private var header: some View {
+ ViewThatFits(in: .horizontal) {
+ HStack(spacing: 12) {
+ searchRow
+ chips
+ Spacer(minLength: 0)
+ }
+ VStack(alignment: .leading, spacing: 10) {
+ searchRow
+ if !visibleCategories.isEmpty {
+ ScrollView(.horizontal, showsIndicators: false) { chips }
+ }
+ }
+ }
+ .padding(.horizontal, 5)
+ }
+
+ private var searchRow: some View {
+ HStack(spacing: 10) {
+ HStack(spacing: 6) {
+ Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
+ TextField(Constants.Fleet.searchPlaceholder, text: $searchText)
+ .textFieldStyle(.plain)
+ if !searchText.isEmpty {
+ Button {
+ searchText = ""
+ } label: {
+ Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ .help(Constants.Fleet.clearSearch)
+ }
+ }
+ .padding(.horizontal, 8)
+ .padding(.vertical, 6)
+ .frame(width: 320)
+ .isGlass()
+ .cornerRadius(8)
+
+ if case .failed(let message) = manager.loadState, !manager.titles.isEmpty {
+ Label(Constants.Fleet.couldNotRefresh, systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundStyle(.orange)
+ .help(message)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private var chips: some View {
+ if !visibleCategories.isEmpty {
+ HStack(spacing: 6) {
+ categoryChip(nil, label: Constants.Fleet.allCategories)
+ ForEach(visibleCategories, id: \.self) { category in
+ categoryChip(category, label: category)
+ }
+ }
+ }
+ }
+
+ private func categoryChip(_ category: String?, label: String) -> some View {
+ let isSelected = selectedCategory == category
+ return Button {
+ selectedCategory = category
+ } label: {
+ Text(label)
+ .font(.callout)
+ .padding(.horizontal, 12)
+ .padding(.vertical, 5)
+ .background(isSelected ? accentColor.opacity(0.25) : Color.secondary.opacity(0.12), in: Capsule())
+ }
+ .buttonStyle(.plain)
+ }
+
+ private var accentColor: Color {
+ Color(NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? .controlAccentColor)
+ }
+
+ /// Categories that at least one title uses, in the order Fleet returns them.
+ private var visibleCategories: [String] {
+ let used = Set(manager.titles.flatMap(\.categories))
+ let ordered = manager.categories.map(\.name).filter { used.contains($0) }
+ let unlisted = used.subtracting(ordered).sorted()
+ return ordered + unlisted
+ }
+
+ // MARK: - Content
+
+ @ViewBuilder
+ private var content: some View {
+ switch manager.loadState {
+ case .notConfigured:
+ message(Constants.Fleet.notConfigured, detail: manager.configurationProblem, systemImage: "exclamationmark.triangle")
+ case .ssoRequired:
+ message(Constants.Fleet.signInRequired, systemImage: "person.badge.key") {
+ ScButton(Constants.Fleet.signIn) {
+ await appState.fleetSSOController.present()
+ }
+ .frame(maxWidth: 200)
+ }
+ case .failed(let error) where manager.titles.isEmpty:
+ message("\(Constants.Fleet.couldNotLoad)\n\(error)", systemImage: "exclamationmark.triangle") {
+ ScButton(Constants.Fleet.retry) {
+ await manager.refresh()
+ }
+ .frame(maxWidth: 200)
+ }
+ case .idle:
+ ProgressView(Constants.Fleet.loading)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ case .loading where manager.titles.isEmpty:
+ ProgressView(Constants.Fleet.loading)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ default:
+ catalog
+ }
+ }
+
+ @ViewBuilder
+ private var catalog: some View {
+ let filtered = filteredTitles
+ if manager.titles.isEmpty {
+ message(Constants.Fleet.noApps, systemImage: "square.grid.2x2")
+ } else if filtered.isEmpty {
+ message(Constants.Fleet.noMatches, systemImage: "magnifyingglass")
+ } else {
+ let recommendedApps = FleetRecommendedApps.current
+ // Recommended apps stay in their section once installed; updates for them are also listed under
+ // Updates Available, so that section matches the update count on Home and in the sidebar
+ let recommended = recommendedApps.titles(from: filtered)
+ let recommendedIDs = Set(recommended.map(\.id))
+ let updates = filtered.filter(manager.hasUpdate)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ section("recommended", recommendedApps.sectionTitle, recommended, highlighted: true)
+ section("updates", Constants.Fleet.updatesAvailable, updates)
+ section("available", Constants.Fleet.available, filtered.filter { !$0.isInstalled && !recommendedIDs.contains($0.id) })
+ section("installed", Constants.Fleet.installed, filtered.filter { $0.isInstalled && !manager.hasUpdate($0) && !recommendedIDs.contains($0.id) })
+ }
+ .padding(.bottom, 20)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func section(_ id: String, _ name: String, _ titles: [FleetSoftwareTitle], highlighted: Bool = false) -> some View {
+ if !titles.isEmpty {
+ let isCollapsed = collapsedSectionIDs.contains(id)
+ VStack(alignment: .leading, spacing: 6) {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) { toggleSection(id) }
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .rotationEffect(.degrees(isCollapsed ? 0 : 90))
+ Text("\(name) (\(titles.count))")
+ .font(.headline)
+ }
+ .padding(.leading, 5)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ if !isCollapsed {
+ LazyVGrid(columns: columns, alignment: .leading, spacing: 0) {
+ ForEach(titles) { title in
+ FleetAppCard(title: title, highlighted: highlighted)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private var collapsedSectionIDs: Set {
+ Set(collapsedSections.split(separator: ",").map(String.init))
+ }
+
+ private func toggleSection(_ id: String) {
+ var ids = collapsedSectionIDs
+ if ids.contains(id) { ids.remove(id) } else { ids.insert(id) }
+ collapsedSections = ids.sorted().joined(separator: ",")
+ }
+
+ private var filteredTitles: [FleetSoftwareTitle] {
+ let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
+ return manager.titles.filter { title in
+ let matchesCategory = selectedCategory.map { title.categories.contains($0) } ?? true
+ let matchesSearch = query.isEmpty
+ || title.title.localizedCaseInsensitiveContains(query)
+ || title.name.localizedCaseInsensitiveContains(query)
+ return matchesCategory && matchesSearch
+ }
+ }
+
+ private func message(_ text: String, detail: String? = nil, systemImage: String, @ViewBuilder action: () -> some View = { EmptyView() }) -> some View {
+ VStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .font(.system(size: 40))
+ .foregroundStyle(.secondary)
+ Text(text)
+ .font(.title3)
+ .multilineTextAlignment(.center)
+ if let detail {
+ Text(detail)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .textSelection(.enabled)
+ }
+ action()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+
+}
diff --git a/SupportCompanion/Views/Fleet/FleetComplianceBanner.swift b/SupportCompanion/Views/Fleet/FleetComplianceBanner.swift
new file mode 100644
index 0000000..1cbcce6
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetComplianceBanner.swift
@@ -0,0 +1,53 @@
+//
+// FleetComplianceBanner.swift
+// SupportCompanion
+//
+// Shown at the top of Home in Fleet mode while any compliance check fails, so it's seen before the cards.
+//
+
+import SwiftUI
+
+struct FleetComplianceBanner: View {
+ @Environment(AppStateManager.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+
+ private var manager: FleetDeviceManager { appState.fleetDeviceManager }
+
+ var body: some View {
+ let failing = manager.failingPolicies
+ let color = failing.contains { $0.critical == true }
+ ? (colorScheme == .light ? Color.redLight : .red)
+ : (colorScheme == .light ? Color.orangeLight : .orange)
+
+ HStack(alignment: .center, spacing: 14) {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .font(.system(size: 26))
+ .foregroundStyle(color)
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(String(format: Constants.Fleet.policiesFailing, failing.count, manager.checkedPolicies.count))
+ .font(.system(size: 16, weight: .semibold))
+ Text(failing.map(\.name).joined(separator: " · "))
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ }
+
+ Spacer(minLength: 12)
+
+ ScButton(Constants.Fleet.complianceDetails, fontSize: 13) {
+ ActionHelpers.openManagementApp(appURL: "supportcompanion://compliance")
+ }
+ FleetRecheckButton(fontSize: 13)
+ }
+ .padding()
+ .isGlass()
+ .cornerRadius(10)
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(color.opacity(0.6), lineWidth: 1.5)
+ )
+ .shadow(radius: 4)
+ .padding(5)
+ }
+}
diff --git a/SupportCompanion/Views/Fleet/FleetComplianceView.swift b/SupportCompanion/Views/Fleet/FleetComplianceView.swift
new file mode 100644
index 0000000..4901294
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetComplianceView.swift
@@ -0,0 +1,281 @@
+//
+// FleetComplianceView.swift
+// SupportCompanion
+//
+// The Compliance page in Fleet mode: every check Fleet runs on this Mac, and how to fix the failing ones.
+// Hide the page with HiddenCards `FleetPolicies`.
+//
+
+import SwiftUI
+
+struct FleetComplianceView: View {
+ @Environment(AppStateManager.self) private var appState
+ @Environment(\.colorScheme) private var colorScheme
+ @State private var showPassing = true
+
+ private var manager: FleetDeviceManager { appState.fleetDeviceManager }
+ private let passingColumns = [GridItem(.adaptive(minimum: 240), alignment: .leading)]
+
+ var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ hero
+ content
+ }
+ .padding(.horizontal, 20)
+ .padding(.top, 16)
+ .padding(.bottom, 24)
+ }
+ .task { await manager.refreshIfStale() }
+ }
+
+ // MARK: - Hero
+
+ @ViewBuilder
+ private var hero: some View {
+ let failing = manager.failingPolicies
+ let checked = manager.checkedPolicies
+ // Signed out there are no policies to count, but the ungated summary still knows how many fail
+ let isFailing = manager.isSignedOut ? (manager.failingChecksCount ?? 0) > 0 : !failing.isEmpty
+ let hasCritical = failing.contains { $0.critical == true }
+ let tint = isFailing ? (hasCritical ? criticalColor : warningColor) : Color.ScGreen
+
+ HStack(alignment: .center, spacing: 16) {
+ Image(systemName: isFailing ? "exclamationmark.shield.fill" : "checkmark.shield.fill")
+ .font(.system(size: 34))
+ .foregroundStyle(tint)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(headline(failing: failing.count, checked: checked.count))
+ .font(.system(size: 19, weight: .semibold))
+ if let checkedAt = lastCheckedText {
+ Text(checkedAt)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Spacer(minLength: 12)
+
+ if let error = manager.refetchError {
+ Label(error, systemImage: "exclamationmark.triangle")
+ .font(.caption)
+ .foregroundStyle(warningColor)
+ }
+
+ if manager.host != nil {
+ FleetRecheckButton(fontSize: 13)
+ }
+ }
+ }
+
+ private func headline(failing: Int, checked: Int) -> String {
+ if manager.isSignedOut {
+ guard let signedOutFailing = manager.failingChecksCount else { return Constants.Fleet.signInForChecks }
+ return signedOutFailing > 0
+ ? String(format: Constants.Fleet.signedOutFailing, signedOutFailing)
+ : Constants.Fleet.signedOutPassing
+ }
+ if checked == 0 { return Constants.Fleet.noPolicies }
+ if failing == 0 { return String(format: Constants.Fleet.policiesPassing, checked) }
+ return String(format: Constants.Fleet.policiesFailing, failing, checked)
+ }
+
+ private var lastCheckedText: String? {
+ guard manager.host != nil,
+ let date = FleetHost.realDate(manager.host?.detailUpdatedAt) else { return nil }
+ if manager.isRefetching { return Constants.Actions.refetching }
+ return String(format: Constants.Fleet.lastChecked, date.relativeDescription())
+ }
+
+ // MARK: - Content
+
+ @ViewBuilder
+ private var content: some View {
+ switch manager.loadState {
+ case .idle:
+ ProgressView()
+ .frame(maxWidth: .infinity)
+ .padding(.top, 60)
+ case .ssoRequired where manager.policies.isEmpty:
+ VStack(spacing: 16) {
+ message(Constants.Fleet.signInForChecks, systemImage: "person.badge.key")
+ ScButton(Constants.Fleet.signIn) {
+ await appState.fleetSSOController.present()
+ }
+ .frame(maxWidth: 200)
+ }
+ .frame(maxWidth: .infinity)
+ case .failed where manager.policies.isEmpty:
+ message(Constants.Fleet.policiesUnavailable, systemImage: "exclamationmark.triangle")
+ default:
+ if manager.checkedPolicies.isEmpty {
+ message(Constants.Fleet.noPolicies, systemImage: "checkmark.shield")
+ } else {
+ VStack(alignment: .leading, spacing: 20) {
+ failingSection
+ passingSection
+ }
+ }
+ }
+ }
+
+ /// The reason the page exists, so the fix is visible rather than hidden behind a disclosure — unless
+ /// there are enough failures that expanding them all would bury the list.
+ @ViewBuilder
+ private var failingSection: some View {
+ let failing = manager.failingPolicies
+ if !failing.isEmpty {
+ VStack(spacing: 10) {
+ ForEach(failing) { policy in
+ FailingPolicyCard(
+ policy: policy,
+ tint: policy.critical == true ? criticalColor : warningColor,
+ startExpanded: failing.count <= 2
+ )
+ }
+ }
+ }
+ }
+
+ /// Reference material once the failures are dealt with, so it stays dense.
+ @ViewBuilder
+ private var passingSection: some View {
+ let passing = manager.passingPolicies
+ if !passing.isEmpty {
+ VStack(alignment: .leading, spacing: 8) {
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) { showPassing.toggle() }
+ } label: {
+ HStack(spacing: 6) {
+ Image(systemName: "chevron.right")
+ .font(.caption.weight(.semibold))
+ .rotationEffect(.degrees(showPassing ? 90 : 0))
+ Text(String(format: Constants.Fleet.passingChecks, passing.count))
+ .font(.system(size: 13, weight: .medium))
+ }
+ .foregroundStyle(.secondary)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ if showPassing {
+ LazyVGrid(columns: passingColumns, alignment: .leading, spacing: 7) {
+ ForEach(passing) { policy in
+ HStack(alignment: .top, spacing: 7) {
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 12))
+ .foregroundStyle(Color.ScGreen)
+ Text(policy.name)
+ .lineLimit(2)
+ .help(policy.description ?? "")
+ Spacer(minLength: 0)
+ }
+ }
+ }
+ .font(.system(size: 13))
+ .foregroundStyle(.secondary)
+ .padding(14)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .isGlass()
+ .cornerRadius(10)
+ }
+ }
+ }
+ }
+
+ private func message(_ text: String, systemImage: String) -> some View {
+ VStack(spacing: 12) {
+ Image(systemName: systemImage)
+ .font(.system(size: 40))
+ .foregroundStyle(.secondary)
+ Text(text)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.top, 60)
+ }
+
+ private var warningColor: Color { colorScheme == .light ? .orangeLight : .orange }
+ private var criticalColor: Color { colorScheme == .light ? .redLight : .red }
+}
+
+private struct FailingPolicyCard: View {
+ let policy: FleetPolicy
+ let tint: Color
+ let startExpanded: Bool
+
+ @State private var isExpanded: Bool?
+ @Environment(\.colorScheme) private var colorScheme
+
+ /// Long lines are hard to scan, so policy text stops well short of the window's width.
+ private static let textWidth: CGFloat = 720
+
+ private var expanded: Bool { isExpanded ?? startExpanded }
+
+ var body: some View {
+ HStack(alignment: .top, spacing: 0) {
+ Rectangle()
+ .fill(tint)
+ .frame(width: 4)
+
+ VStack(alignment: .leading, spacing: 6) {
+ HStack(spacing: 8) {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(tint)
+ Text(policy.name)
+ .font(.system(size: 15, weight: .semibold))
+ if policy.critical == true {
+ Text(Constants.Fleet.critical)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 1)
+ .background(tint.opacity(0.18), in: Capsule())
+ .foregroundStyle(tint)
+ }
+ Spacer(minLength: 0)
+ }
+
+ VStack(alignment: .leading, spacing: 8) {
+ if let description = trimmed(policy.description) {
+ Text(markdown(description))
+ .foregroundStyle(.secondary)
+ }
+ if let resolution = trimmed(policy.resolution) {
+ DisclosureGroup(
+ Constants.Fleet.howToFix,
+ isExpanded: Binding(get: { expanded }, set: { isExpanded = $0 })
+ ) {
+ Text(markdown(resolution))
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.top, 4)
+ }
+ }
+ }
+ .font(.system(size: 13))
+ .frame(maxWidth: Self.textWidth, alignment: .leading)
+ .padding(.leading, 24)
+ }
+ .padding(.horizontal, 14)
+ .padding(.vertical, 12)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(tint.opacity(colorScheme == .light ? 0.06 : 0.11))
+ .isGlass()
+ .cornerRadius(10)
+ }
+
+ private func trimmed(_ text: String?) -> String? {
+ let value = text?.trimmingCharacters(in: .whitespacesAndNewlines)
+ return (value?.isEmpty ?? true) ? nil : value
+ }
+
+ /// Fleet descriptions and resolutions are often written in Markdown, e.g. with links.
+ private func markdown(_ text: String) -> AttributedString {
+ let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)
+ return (try? AttributedString(markdown: text, options: options)) ?? AttributedString(text)
+ }
+}
diff --git a/SupportCompanion/Views/Fleet/FleetInstallDetailsSheet.swift b/SupportCompanion/Views/Fleet/FleetInstallDetailsSheet.swift
new file mode 100644
index 0000000..6e8d64f
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetInstallDetailsSheet.swift
@@ -0,0 +1,62 @@
+//
+// FleetInstallDetailsSheet.swift
+// SupportCompanion
+//
+// Output of a title's last failed install or uninstall, as reported by Fleet.
+//
+
+import SwiftUI
+
+struct FleetInstallDetailsSheet: View {
+ let title: FleetSoftwareTitle
+
+ @Environment(AppStateManager.self) private var appState
+ @Environment(\.dismiss) private var dismiss
+ @State private var output: String?
+ @State private var error: String?
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ Text(title.status == .failedUninstall ? Constants.Fleet.uninstallDetailsTitle : Constants.Fleet.installDetailsTitle)
+ .font(.title2.weight(.semibold))
+ Text(title.title)
+ .foregroundStyle(.secondary)
+
+ Group {
+ if let error {
+ Label("\(Constants.Fleet.couldNotLoadDetails): \(error)", systemImage: "exclamationmark.triangle")
+ .foregroundStyle(.orange)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ } else if let output {
+ ScrollView {
+ Text(output.isEmpty ? Constants.Fleet.noOutput : output)
+ .font(.system(.callout, design: .monospaced))
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .topLeading)
+ .padding(10)
+ }
+ .background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 8))
+ } else {
+ ProgressView()
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ .frame(maxHeight: .infinity)
+
+ HStack {
+ Spacer()
+ Button(Constants.Fleet.close) { dismiss() }
+ .keyboardShortcut(.defaultAction)
+ }
+ }
+ .padding(20)
+ .frame(width: 560, height: 420)
+ .task {
+ do {
+ output = try await appState.fleetSoftwareManager.failureOutput(for: title)
+ } catch {
+ self.error = error.localizedDescription
+ }
+ }
+ }
+}
diff --git a/SupportCompanion/Views/Fleet/FleetRecheckButton.swift b/SupportCompanion/Views/Fleet/FleetRecheckButton.swift
new file mode 100644
index 0000000..aa7be2c
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetRecheckButton.swift
@@ -0,0 +1,36 @@
+//
+// FleetRecheckButton.swift
+// SupportCompanion
+//
+// Asks Fleet to re-read this Mac's details and re-run its compliance checks.
+//
+
+import SwiftUI
+
+struct FleetRecheckButton: View {
+ var fontSize: CGFloat?
+ /// The small style used on the menu bar's compact cards.
+ var compact = false
+ @Environment(AppStateManager.self) private var appState
+
+ var body: some View {
+ let manager = appState.fleetDeviceManager
+ let title = manager.isRefetching ? Constants.Actions.refetching : Constants.Actions.refetch
+ // Failures are shown by the cards through refetchError
+ if compact {
+ ScSmallButton(title, disabled: manager.isRefetching) {
+ try? await manager.refetch()
+ }
+ .help(manager.refetchError ?? Constants.Actions.refetchHelp)
+ } else {
+ ScButton(
+ title,
+ helpText: manager.refetchError ?? Constants.Actions.refetchHelp,
+ disabled: manager.isRefetching,
+ fontSize: fontSize
+ ) {
+ try? await manager.refetch()
+ }
+ }
+ }
+}
diff --git a/SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift b/SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift
new file mode 100644
index 0000000..14a29e1
--- /dev/null
+++ b/SupportCompanion/Views/Fleet/FleetSSOSignInSheet.swift
@@ -0,0 +1,109 @@
+//
+// FleetSSOSignInSheet.swift
+// SupportCompanion
+//
+// Fleet Desktop SSO sign-in: the identity provider's own page, hosted in the app.
+//
+
+import SwiftUI
+import WebKit
+
+struct FleetSSOSignInSheet: View {
+ var controller: FleetSSOController
+
+ var body: some View {
+ VStack(spacing: 0) {
+ header
+ Divider()
+ content
+ }
+ .frame(width: 620, height: 640)
+ }
+
+ private var header: some View {
+ HStack {
+ Label(Constants.Fleet.ssoSheetTitle, systemImage: "person.badge.key")
+ .font(.headline)
+ Spacer()
+ Button(Constants.Fleet.cancel) { controller.cancel() }
+ .keyboardShortcut(.cancelAction)
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 14)
+ }
+
+ @ViewBuilder
+ private var content: some View {
+ switch controller.phase {
+ case .failed(let message):
+ status(systemImage: "exclamationmark.triangle", title: Constants.Fleet.ssoCouldNotSignIn, detail: message) {
+ ScButton(Constants.Fleet.retry) {
+ await controller.start()
+ }
+ .frame(maxWidth: 200)
+ }
+ case .signingIn:
+ if let webView = controller.webView {
+ // The identity provider's page, not Fleet's: the app never sees what's typed into it.
+ // Keyed by instance so a retry shows its new web view rather than the failed one.
+ FleetSSOWebView(webView: webView)
+ .id(ObjectIdentifier(webView))
+ } else {
+ status(systemImage: nil, title: Constants.Fleet.ssoConnecting, detail: nil) {
+ ProgressView()
+ }
+ }
+ default:
+ status(systemImage: nil, title: Constants.Fleet.ssoConnecting, detail: nil) {
+ ProgressView()
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func status(
+ systemImage: String?,
+ title: String,
+ detail: String?,
+ @ViewBuilder accessory: () -> Accessory
+ ) -> some View {
+ VStack(spacing: 12) {
+ if let systemImage {
+ Image(systemName: systemImage)
+ .font(.system(size: 30))
+ .foregroundStyle(.secondary)
+ }
+ Text(title)
+ .font(.headline)
+ if let detail, !detail.isEmpty {
+ Text(detail)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .textSelection(.enabled)
+ }
+ accessory()
+ }
+ .padding(30)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+/// Hosts the web view the controller owns, so its navigation delegate survives SwiftUI re-evaluating
+/// this view — see WebViewContainer for why no coordinator sets a delegate here.
+private struct FleetSSOWebView: NSViewRepresentable {
+ let webView: WKWebView
+
+ func makeNSView(context: Context) -> WKWebView { webView }
+
+ func updateNSView(_ nsView: WKWebView, context: Context) {}
+
+ /// Take the size offered rather than asking the web view how big the loaded page wants to be,
+ /// which is a layout cycle.
+ func sizeThatFits(_ proposal: ProposedViewSize, nsView: WKWebView, context: Context) -> CGSize? {
+ CGSize(
+ width: proposal.width ?? nsView.frame.width,
+ height: proposal.height ?? nsView.frame.height
+ )
+ }
+}
diff --git a/SupportCompanion/Views/Identity.swift b/SupportCompanion/Views/Identity.swift
index f5bda83..f2ebca5 100644
--- a/SupportCompanion/Views/Identity.swift
+++ b/SupportCompanion/Views/Identity.swift
@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
struct Identity: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
@@ -26,7 +26,7 @@ struct Identity: View {
if !appState.ssoInfoManager.platformSSO.loginType.isEmpty {
PSSOCard()
}
- if appState.preferences.enableElevation {
+ if appState.preferences.elevation.enableElevation {
ElevationCard()
}
}
diff --git a/SupportCompanion/Views/ReasonInput.swift b/SupportCompanion/Views/ReasonInput.swift
index f091508..cbb0c67 100644
--- a/SupportCompanion/Views/ReasonInput.swift
+++ b/SupportCompanion/Views/ReasonInput.swift
@@ -47,7 +47,7 @@ struct ReasonInputView: View {
onElevate(reason)
isPresented = false
}
- .disabled(reason.count < appState.preferences.reasonMinLength)
+ .disabled(reason.count < appState.preferences.elevation.reasonMinLength)
.padding()
}
}
diff --git a/SupportCompanion/Views/ScButton.swift b/SupportCompanion/Views/ScButton.swift
index 7cf28a3..2530694 100644
--- a/SupportCompanion/Views/ScButton.swift
+++ b/SupportCompanion/Views/ScButton.swift
@@ -16,8 +16,7 @@ struct ScButton: View, Hashable {
let disabled: Bool?
var maxWidth: CGFloat? // New parameter for button width
let fontSize: CGFloat?
- @EnvironmentObject var preferences: Preferences
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@State private var isHovered = false
@State private var showBadge = false
@State private var isLoading = false
@@ -74,26 +73,28 @@ struct ScButton: View, Hashable {
}
.padding()
.frame(maxWidth: maxWidth ?? nil) // Keep consistent button size
- .background(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor))
+ .background(Color(NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? NSColor.controlAccentColor))
.foregroundColor(.white)
.cornerRadius(12)
.multilineTextAlignment(.leading)
}
.buttonStyle(PlainButtonStyle())
.disabled(disabled ?? false || isLoading)
-
- // Badge
- if let badgeNumber = badgeNumber, badgeNumber > 0 {
- Text("\(badgeNumber)")
- .font(.caption)
- .foregroundColor(.white)
- .padding(8)
- .background(Color.red)
- .clipShape(Circle())
- .offset(x: 10, y: -10)
- .opacity(showBadge ? 1.0 : 0.0) // Control visibility
- .scaleEffect(showBadge ? 1.0 : 0.5) // Add scaling effect
- .animation(.easeInOut(duration: 0.3), value: showBadge) // Smooth animation
+ .buttonStyle(PlainButtonStyle())
+ .disabled(disabled ?? false || isLoading)
+ .overlay(alignment: .topTrailing) {
+ if let badgeNumber = badgeNumber, badgeNumber > 0 {
+ Text("\(badgeNumber)")
+ .font(.caption.weight(.bold))
+ .foregroundColor(.white)
+ .padding(8)
+ .background(Circle().fill(Color(red: 1, green: 0, blue: 0)))
+ .clipShape(Circle())
+ .offset(x: 10, y: -10)
+ .opacity(showBadge ? 1 : 0)
+ .scaleEffect(showBadge ? 1 : 0.5)
+ .animation(.easeInOut(duration: 0.3), value: showBadge)
+ }
}
}
.scaleEffect(isHovered ? 1.1 : 1.0) // Apply hover effect to the whole stack
diff --git a/SupportCompanion/Views/ScCard.swift b/SupportCompanion/Views/ScCard.swift
index d5dd934..3d7c2d3 100644
--- a/SupportCompanion/Views/ScCard.swift
+++ b/SupportCompanion/Views/ScCard.swift
@@ -19,8 +19,7 @@ struct ScCard: View {
let buttonHelpText: String?
let imageSize: (CGFloat, CGFloat)?
let useMultiColor: Bool?
- @EnvironmentObject var preferences: Preferences
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
init(
title: String,
@@ -90,12 +89,10 @@ struct ScCard: View {
await buttonAction()
}
}) {
- Image(systemName: buttonImageName)
- .font(.system(size: 16))
- .foregroundColor(Color(NSColor(hex: appState.preferences.accentColor ?? "") ?? NSColor.controlAccentColor))
+ InfoHelp(text: buttonHelpText ?? "", icon: buttonImageName, color: Color(NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? NSColor.controlAccentColor))
}
.buttonStyle(PlainButtonStyle())
- .help(buttonHelpText ?? "")
+ .accessibilityLabel(buttonHelpText ?? buttonImageName)
}
}
.padding()
@@ -126,10 +123,6 @@ struct ScCard: View {
Spacer()
}
.padding()
- /*.background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(.ultraThinMaterial)
- )*/
.isGlass()
.cornerRadius(10)
.shadow(radius: 4)
diff --git a/SupportCompanion/Views/ScCardCompact.swift b/SupportCompanion/Views/ScCardCompact.swift
index 74b481d..714d3f6 100644
--- a/SupportCompanion/Views/ScCardCompact.swift
+++ b/SupportCompanion/Views/ScCardCompact.swift
@@ -99,12 +99,7 @@ struct ScCardCompact: View {
Spacer() // Allow the card to expand vertically
}
.padding(.top) // Padding around the entire card
- /*.background(
- RoundedRectangle(cornerRadius: 10)
- .fill(.ultraThinMaterial)
- )*/
.isGlass()
- //.shadow(radius: 4) // Adjust shadow for better appearance
.padding(5)
}
}
diff --git a/SupportCompanion/Views/ScCardCompactButton.swift b/SupportCompanion/Views/ScCardCompactButton.swift
index a959084..1396e5f 100644
--- a/SupportCompanion/Views/ScCardCompactButton.swift
+++ b/SupportCompanion/Views/ScCardCompactButton.swift
@@ -55,8 +55,12 @@ struct ScCardCompactButton: View {
if let buttonAction = buttonAction {
Task {
isRunning = true // Set running state to true
- _ = try await ExecutionService.executeShellCommand(buttonAction.command, isPrivileged: buttonAction.isPrivileged)
- isRunning = false // Reset running state
+ defer { isRunning = false } // Reset even if the action throws, or the button sticks
+ do {
+ _ = try await ExecutionService.runAction(buttonAction)
+ } catch {
+ Logger.shared.logError("Action '\(buttonAction.name)' failed: \(error)")
+ }
}
}
}) {
diff --git a/SupportCompanion/Views/ScSmallButton.swift b/SupportCompanion/Views/ScSmallButton.swift
new file mode 100644
index 0000000..e65a294
--- /dev/null
+++ b/SupportCompanion/Views/ScSmallButton.swift
@@ -0,0 +1,41 @@
+//
+// ScSmallButton.swift
+// SupportCompanion
+//
+// The small button used on the menu bar's compact cards.
+//
+
+import SwiftUI
+
+struct ScSmallButton: View {
+ let title: String
+ var disabled = false
+ let action: () async -> Void
+
+ @Environment(AppStateManager.self) private var appState
+ @State private var isRunning = false
+
+ init(_ title: String, disabled: Bool = false, action: @escaping () async -> Void) {
+ self.title = title
+ self.disabled = disabled
+ self.action = action
+ }
+
+ var body: some View {
+ Button {
+ Task {
+ isRunning = true
+ await action()
+ isRunning = false
+ }
+ } label: {
+ ButtonTitle(title: title, fontSize: 12, isLoading: isRunning)
+ .padding(8)
+ .background(Color(NSColor(hex: appState.preferences.branding.accentColor ?? "") ?? NSColor.controlAccentColor))
+ .foregroundColor(.white)
+ .cornerRadius(8)
+ }
+ .buttonStyle(PlainButtonStyle())
+ .disabled(disabled || isRunning)
+ }
+}
diff --git a/SupportCompanion/Views/SelfService.swift b/SupportCompanion/Views/SelfService.swift
index 49e8bed..688cacc 100644
--- a/SupportCompanion/Views/SelfService.swift
+++ b/SupportCompanion/Views/SelfService.swift
@@ -9,7 +9,7 @@ import Foundation
import SwiftUI
struct SelfService: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
var body: some View {
let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
@@ -22,7 +22,6 @@ struct SelfService: View {
}
}
.padding(.horizontal, 20)
- //.padding(.top, 20)
.id(appState.preferences.actions)
}
.onAppear {
@@ -36,7 +35,6 @@ struct SelfService: View {
struct SelfServiceCard: View {
let action: Action
- //@State private var isRunning: Bool = false
var body: some View {
ScCard(
@@ -53,19 +51,14 @@ struct SelfService: View {
Spacer() // Push the button to the bottom
- /*if isRunning {
- ProgressView()
- .progressViewStyle(CircularProgressViewStyle())
- .padding()
- .frame(maxWidth: .infinity, alignment: .center)
- } else {*/
ScButton(action.buttonLabel ?? "Run", maxWidth: 150) {
- //isRunning = true
- //defer { isRunning = false }
- _ = try? await ExecutionService.executeShellCommand(action.command, isPrivileged: action.isPrivileged)
+ do {
+ _ = try await ExecutionService.runAction(action)
+ } catch {
+ Logger.shared.logError("Self Service action '\(action.command)' failed: \(error)")
+ }
}
.frame(maxWidth: .infinity, alignment: .bottom)
- //}
}
.frame(maxHeight: .infinity) // Ensure the VStack takes full available height
.padding(.horizontal)
diff --git a/SupportCompanion/Views/TransparentView.swift b/SupportCompanion/Views/TransparentView.swift
index 66fe027..4eb11be 100644
--- a/SupportCompanion/Views/TransparentView.swift
+++ b/SupportCompanion/Views/TransparentView.swift
@@ -10,15 +10,15 @@ import SwiftUI
struct TransparentView: View {
- @EnvironmentObject var appState: AppStateManager
+ @Environment(AppStateManager.self) var appState
@State private var contentHeight: CGFloat = 0
var combinedPreferences: String {
- "\(appState.preferences.desktopInfoLevel)-\(appState.preferences.desktopInfoHideItems.joined(separator: ","))"
+ "\(appState.preferences.desktopInfo.desktopInfoLevel)-\(appState.preferences.desktopInfo.desktopInfoHideItems.joined(separator: ","))"
}
var body: some View {
ZStack {
- if appState.preferences.desktopInfoBackgroundFrosted {
+ if appState.preferences.desktopInfo.desktopInfoBackgroundFrosted {
BlurEffectView(
material: .fullScreenUI,
blendingMode: .behindWindow
@@ -28,15 +28,17 @@ struct TransparentView: View {
}
RoundedRectangle(cornerRadius: 15)
- .fill(Color.black.opacity(appState.preferences.desktopInfoBackgroundOpacity))
+ .fill(Color.black.opacity(appState.preferences.desktopInfo.desktopInfoBackgroundOpacity))
.shadow(radius: 10) // Shadow for depth
.clipShape(RoundedRectangle(cornerRadius: 15))
.isGlass()
VStack(alignment: .leading) {
// Title for the Info View
- if !appState.preferences.desktopInfoHideItems.contains("Category") {
- Text(Constants.CardTitle.deviceInfo)
+ if !appState.preferences.desktopInfo.desktopInfoHideItems.contains("Category") {
+ let trimmedBrand = appState.preferences.branding.brandName.trimmingCharacters(in: .whitespacesAndNewlines)
+ let useBrand = !trimmedBrand.isEmpty && trimmedBrand.caseInsensitiveCompare("Support Companion") != .orderedSame
+ Text(useBrand ? trimmedBrand : Constants.CardTitle.deviceInfo)
.font(.title2)
.bold()
.foregroundColor(.white)
@@ -49,13 +51,13 @@ struct TransparentView: View {
SectionHeaderTransparent(
title: group.0,
addHeader: shouldShowGategory(),
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
) // Section title (e.g., "Hardware Specifications")
VStack(alignment: .leading) {
- ForEach(group.1.filter { !appState.preferences.desktopInfoHideItems.contains($0.key) }, id: \.key) { item in
+ ForEach(group.1.filter { !appState.preferences.desktopInfo.desktopInfoHideItems.contains($0.key) }, id: \.key) { item in
deviceInfoRow(for: item)
}
- .id(appState.preferences.desktopInfoHideItems)
+ .id(appState.preferences.desktopInfo.desktopInfoHideItems)
}
// Add a divider only if it's not the last group
@@ -67,7 +69,7 @@ struct TransparentView: View {
}
}
- if appState.preferences.desktopInfoLevel > 3 && !appState.preferences.desktopInfoHideItems.contains("Storage"){
+ if appState.preferences.desktopInfo.desktopInfoLevel > 3 && !appState.preferences.desktopInfo.desktopInfoHideItems.contains("Storage"){
// Storage Section
shouldShowDivider()
.background(Color.white.opacity(0.2))
@@ -77,12 +79,12 @@ struct TransparentView: View {
SectionHeaderTransparent(
title: Constants.CardTitle.storage,
addHeader: shouldShowGategory(),
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
)
storageInfoSection()
}
- if appState.preferences.desktopInfoLevel > 4 && !appState.preferences.desktopInfoHideItems.contains("Support"){
+ if appState.preferences.desktopInfo.desktopInfoLevel > 4 && !appState.preferences.desktopInfo.desktopInfoHideItems.contains("Support"){
shouldShowDivider()
.background(Color.white.opacity(0.2))
.shadow(radius: 2)
@@ -91,7 +93,7 @@ struct TransparentView: View {
SectionHeaderTransparent(
title: Constants.Support.Titles.support,
addHeader: shouldShowGategory(),
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
)
supportInfoSection()
}
@@ -112,17 +114,17 @@ struct TransparentView: View {
}
private func shouldShowDivider() -> some View {
- !appState.preferences.desktopInfoHideItems.contains("Divider")
+ !appState.preferences.desktopInfo.desktopInfoHideItems.contains("Divider")
? AnyView(Divider())
: AnyView(EmptyView())
}
private func shouldShowGategory() -> Bool {
- !appState.preferences.desktopInfoHideItems.contains("Category")
+ !appState.preferences.desktopInfo.desktopInfoHideItems.contains("Category")
}
private func localizedHideCheck(_ standardKey: String) -> Bool {
- let hideItems = appState.preferences.desktopInfoHideItems
+ let hideItems = appState.preferences.desktopInfo.desktopInfoHideItems
// Map the user-provided keys to localized values
let localizedKeys = hideItems.compactMap { key in
@@ -146,7 +148,7 @@ struct TransparentView: View {
groupedDeviceInfo()
.compactMap { section in
let isIncludedByLevel: Bool
- switch appState.preferences.desktopInfoLevel {
+ switch appState.preferences.desktopInfo.desktopInfoLevel {
case 1:
isIncludedByLevel = section.key == Constants.DeviceInfo.Categories.hardwareSpecs
case 2:
@@ -184,7 +186,7 @@ struct TransparentView: View {
}
private func deviceInfoRow(for item: (key: String, display: String, value: InfoValue)) -> some View {
- if item.key == "lastRestartDays" {
+ if item.key == Constants.DeviceInfo.Keys.lastRestartDays {
return AnyView(EmptyView())
} else {
if item.key == Constants.DeviceInfo.Keys.lastRestart {
@@ -192,7 +194,7 @@ struct TransparentView: View {
LastRestartRowTransparent(
label: item.display,
value: item.value.rawValue as? Int ?? 0,
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
)
)
} else {
@@ -200,7 +202,7 @@ struct TransparentView: View {
DeviceInfoRowTransparent(
label: item.display,
value: item.value.displayValue,
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
)
.id(item.value.displayValue)
)
@@ -211,59 +213,61 @@ struct TransparentView: View {
private func storageInfoSection() -> some View {
VStack(alignment: .leading) {
ForEach(appState.storageInfoManager.storageInfo.toKeyValuePairs(), id: \.key) { item in
- if appState.preferences.desktopInfoHideItems.count > 0 {
- if appState.preferences.desktopInfoHideItems.contains(item.key) {
+ if appState.preferences.desktopInfo.desktopInfoHideItems.count > 0 {
+ if appState.preferences.desktopInfo.desktopInfoHideItems.contains(item.key) {
EmptyView()
}
}
if item.key == "FileVault" {
- StorageInfoRowTransparent(
- label: item.display,
- value: item.value.displayValue,
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
- )
+ if !appState.preferences.desktopInfo.desktopInfoHideItems.contains(Constants.Storage.Keys.fileVault) {
+ StorageInfoRowTransparent(
+ label: item.display,
+ value: item.value.displayValue,
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
+ )
+ }
usageInfoRowTransparent(
value: appState.storageInfoManager.storageInfo.usage,
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
)
} else {
StorageInfoRowTransparent(
label: item.display,
value: item.value.displayValue,
- fontSize: CGFloat(appState.preferences.desktopInfoFontSize)
+ fontSize: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)
)
}
}
- .id(appState.preferences.desktopInfoHideItems)
+ .id(appState.preferences.desktopInfo.desktopInfoHideItems)
}
}
private func supportInfoSection() -> some View {
VStack(alignment: .leading) {
- if !appState.preferences.desktopInfoHideItems.contains(Constants.Support.Keys.phone) {
+ if !appState.preferences.desktopInfo.desktopInfoHideItems.contains(Constants.Support.Keys.phone) {
HStack {
Text(Constants.Support.Labels.phone)
- .font(.system(size: CGFloat(appState.preferences.desktopInfoFontSize)))
+ .font(.system(size: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)))
.bold()
Spacer()
Text(appState.preferences.supportPhone)
- .font(.system(size: CGFloat(appState.preferences.desktopInfoFontSize)))
+ .font(.system(size: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)))
.shadow(radius: 2)
}
}
- if !appState.preferences.desktopInfoHideItems.contains(Constants.Support.Keys.email) {
+ if !appState.preferences.desktopInfo.desktopInfoHideItems.contains(Constants.Support.Keys.email) {
HStack {
Text(Constants.Support.Labels.email)
- .font(.system(size: CGFloat(appState.preferences.desktopInfoFontSize)))
+ .font(.system(size: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)))
.bold()
Spacer()
Text(appState.preferences.supportEmail)
- .font(.system(size: CGFloat(appState.preferences.desktopInfoFontSize)))
+ .font(.system(size: CGFloat(appState.preferences.desktopInfo.desktopInfoFontSize)))
.shadow(radius: 2)
}
}
}
- .id(appState.preferences.desktopInfoHideItems)
+ .id(appState.preferences.desktopInfo.desktopInfoHideItems)
}
}
@@ -402,3 +406,4 @@ struct LastRestartRowTransparent: View {
}
}
}
+
diff --git a/SupportCompanion/Views/TrayMenu.swift b/SupportCompanion/Views/TrayMenu.swift
index 1be658b..fe39ca6 100644
--- a/SupportCompanion/Views/TrayMenu.swift
+++ b/SupportCompanion/Views/TrayMenu.swift
@@ -9,8 +9,8 @@ import Foundation
import SwiftUI
struct TrayMenuView: View {
- @EnvironmentObject var appState: AppStateManager
- @ObservedObject var viewModel: CardGridViewModel
+ @Environment(AppStateManager.self) var appState
+ var viewModel: CardGridViewModel
@Environment(\.colorScheme) var colorScheme
@State private var brandLogo: Image? = nil
@State private var showLogo: Bool = false
@@ -36,8 +36,8 @@ struct TrayMenuView: View {
}
// Title Section
- if !appState.preferences.brandName.isEmpty {
- Text(appState.preferences.brandName)
+ if !appState.preferences.branding.brandName.isEmpty {
+ Text(appState.preferences.branding.brandName)
.font(.headline)
}
//Spacer()
@@ -54,14 +54,25 @@ struct TrayMenuView: View {
if !appState.preferences.hiddenCards.contains(Constants.Cards.storage) {
CompactStorageCard()
}
- if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune {
+ if viewModel.hasManagementMode {
if !appState.preferences.hiddenCards.contains(Constants.Cards.appPatchProgress) {
CompactPatchProgressCard()
}
}
- if appState.preferences.enableElevation && appState.preferences.showElevateTrayCard {
+ if appState.preferences.elevation.enableElevation && appState.preferences.elevation.showElevateTrayCard {
CompactElevationCard()
}
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.jamfInfo) && appState.preferences.mode == Constants.Modes.jamf {
+ CompactJamfInfoCard()
+ }
+ if appState.preferences.mode == Constants.Modes.fleet {
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.fleetPolicies) {
+ CompactFleetPoliciesCard()
+ }
+ if !appState.preferences.hiddenCards.contains(Constants.Cards.fleetInfo) {
+ CompactFleetInfoCard()
+ }
+ }
}
Divider()
@@ -130,7 +141,7 @@ struct TrayMenuView: View {
if !appState.preferences.hiddenCards.contains(Constants.Cards.battery) { count += 1 }
if !appState.preferences.hiddenCards.contains(Constants.Cards.deviceInfo) { count += 1 }
if !appState.preferences.hiddenCards.contains(Constants.Cards.storage) { count += 1 }
- if appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune {
+ if viewModel.hasManagementMode {
if !appState.preferences.hiddenCards.contains(Constants.Cards.appPatchProgress) {
count += 1
}
@@ -146,7 +157,7 @@ struct TrayMenuView: View {
AppStateManager.shared.preferences.menuShowApps,
AppStateManager.shared.preferences.menuShowSelfService,
viewModel.isButtonVisible(Constants.Actions.HideStrings.changePassword),
- appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune,
+ viewModel.hasManagementMode,
viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport),
viewModel.isButtonVisible(Constants.Actions.HideStrings.gatherLogs),
viewModel.isButtonVisible(Constants.Actions.HideStrings.softwareUpdate),
@@ -160,7 +171,7 @@ struct TrayMenuView: View {
showLogo = false
return
}
- let base64Logo = colorScheme == .dark ? appState.preferences.brandLogo : appState.preferences.brandLogoLight.isEmpty ? appState.preferences.brandLogo : appState.preferences.brandLogoLight
+ let base64Logo = colorScheme == .dark ? appState.preferences.branding.brandLogo : appState.preferences.branding.brandLogoLight.isEmpty ? appState.preferences.branding.brandLogo : appState.preferences.branding.brandLogoLight
showLogo = loadLogo(base64Logo: base64Logo)
if showLogo {
brandLogo = base64ToImage(base64Logo)
@@ -169,20 +180,20 @@ struct TrayMenuView: View {
}
struct ButtonSection: View {
- @ObservedObject var viewModel: CardGridViewModel
+ var viewModel: CardGridViewModel
let url = "supportcompanion://"
let appState: AppStateManager
var body: some View {
let visibleButtons = [
ScButton(Constants.TrayMenu.openApp, fontSize: 12, action: {
- DispatchQueue.main.async {
- appState.showWindowCallback?()
- }
+ Task { @MainActor in appState.showWindowCallback?() }
}),
viewModel.isButtonVisible(Constants.Actions.HideStrings.changePassword) ? viewModel.createChangePasswordButton(fontSize: 12) : nil,
- viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport) ? ScButton(Constants.Actions.getSupport, fontSize: 12) { ActionHelpers.openSupportPage(url: appState.preferences.supportPageURL) } : nil,
- (appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune)
+ viewModel.isButtonVisible(Constants.Actions.HideStrings.getSupport) && !appState.preferences.supportPageURL.isEmpty ? ScButton(
+ Constants.Actions.getSupport, fontSize: 12)
+ { await ActionHelpers.openSupportPage(url: appState.preferences.supportPageURL) } : nil,
+ (viewModel.hasManagementMode)
? (viewModel.isButtonVisible(Constants.Actions.HideStrings.openManagementApp) ? viewModel.createOpenManagementAppButton(type: .default, fontSize: 12) : nil)
: nil,
viewModel.isButtonVisible(Constants.Actions.HideStrings.gatherLogs) ? viewModel.createGatherLogsButton(fontSize: 12) : nil,
@@ -191,8 +202,10 @@ struct ButtonSection: View {
badgeNumber: appState.systemUpdateCache.updates.count,
helpText: appState.systemUpdateCache.updates.joined(separator: "\n"),
fontSize: 12)
- { ActionHelpers.openSystemUpdates() } : nil,
- (appState.preferences.mode == Constants.modes.munki || appState.preferences.mode == Constants.modes.intune)
+ { [hasBackgroundSecurityImprovement = appState.systemUpdateCache.hasBackgroundSecurityImprovement] in
+ hasBackgroundSecurityImprovement ? ActionHelpers.openBackgroundSecurityImprovements() : ActionHelpers.openSystemUpdates()
+ } : nil,
+ (appState.preferences.mode == Constants.Modes.munki || appState.preferences.mode == Constants.Modes.intune)
? (viewModel.isButtonVisible(Constants.Actions.HideStrings.restartIntuneAgent) ? viewModel.createRestartIntuneAgentButton(fontSize: 12) : nil)
: nil
].compactMap { $0 } // Remove nil values
diff --git a/SupportCompanion/Views/UserInstallView.swift b/SupportCompanion/Views/UserInstallView.swift
new file mode 100644
index 0000000..a28cc08
--- /dev/null
+++ b/SupportCompanion/Views/UserInstallView.swift
@@ -0,0 +1,495 @@
+//
+// UserInstallView.swift
+// SupportCompanion
+//
+
+import SwiftUI
+
+/// What the user sees after opening an installer the administrator may have allowed.
+///
+/// The decision has already been made by the helper by the time this is drawn, so the window has one
+/// job: say what this is, say whether it is allowed, and offer the one action worth taking. The weight
+/// follows that — the application's name is the largest thing here, the action is the only filled
+/// button, and what was checked sits underneath quietly, there to be believed rather than read.
+struct UserInstallView: View {
+
+ let manager: UserInstallManager
+
+ /// Collapsed by default. What is behind it is for whoever writes the profile, not for the person
+ /// who just wanted to install something.
+ @State private var showDetails = false
+
+ var body: some View {
+ VStack(spacing: 0) {
+ switch manager.stage {
+ case .assessing(let fileName):
+ busy(title: Constants.UserInstalls.checking, subtitle: fileName)
+
+ case .allowed(let assessment):
+ allowed(assessment)
+
+ case .refused(let assessment):
+ refused(assessment)
+
+ case .installing(let assessment):
+ busy(
+ title: Constants.UserInstalls.installing,
+ subtitle: assessment.facts.displayName ?? assessment.facts.fileName
+ )
+
+ case .installed(let assessment):
+ finished(assessment)
+
+ case .failed(let assessment, let message):
+ failed(assessment, message: message)
+
+ case nil:
+ EmptyView()
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .animation(.easeInOut(duration: 0.18), value: manager.stage)
+ }
+
+ // MARK: States
+
+ private func busy(title: String, subtitle: String) -> some View {
+ VStack(spacing: 12) {
+ ProgressView()
+ .controlSize(.large)
+ .padding(.bottom, 4)
+
+ Text(title)
+ .font(.system(size: 15, weight: .semibold))
+
+ Text(subtitle)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .lineLimit(2)
+ .multilineTextAlignment(.center)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .padding(Layout.margin)
+ }
+
+ private func allowed(_ assessment: InstallerAssessment) -> some View {
+ body(
+ assessment,
+ status: Status(
+ text: Constants.UserInstalls.allowedByAdministrator,
+ symbol: "checkmark.seal.fill",
+ tint: .green
+ ),
+ detail: { details(assessment) },
+ actions: {
+ quiet(Constants.General.cancel) { manager.cancel() }
+ primary(Constants.UserInstalls.install) { manager.install() }
+ }
+ )
+ }
+
+ private func refused(_ assessment: InstallerAssessment) -> some View {
+ body(
+ assessment,
+ status: Status(
+ text: Constants.UserInstalls.notAllowed,
+ symbol: "exclamationmark.triangle.fill",
+ tint: .orange
+ ),
+ detail: {
+ VStack(alignment: .leading, spacing: 14) {
+ reasons(assessment.rejectionReasons)
+
+ if let suggestion = manager.catalogSuggestion(for: assessment.facts) {
+ catalogBanner(suggestion)
+ }
+
+ DisclosureGroup(isExpanded: $showDetails) {
+ identity(assessment)
+ .padding(.top, 8)
+ } label: {
+ Text(Constants.UserInstalls.details)
+ .font(.system(size: 11, weight: .medium))
+ .foregroundStyle(.secondary)
+ }
+ .tint(.secondary)
+ }
+ },
+ actions: {
+ quiet(Constants.General.close) { manager.cancel() }
+
+ // When the organisation already ships this, that is the answer — not administrator
+ // rights, and not a password prompt the user cannot satisfy. The other routes are
+ // deliberately not offered alongside it.
+ if let suggestion = manager.catalogSuggestion(for: assessment.facts) {
+ primary(Constants.UserInstalls.showInCatalog) { manager.showCatalog(suggestion) }
+ } else {
+ switch assessment.fallback {
+ case .elevate:
+ primary(Constants.UserInstalls.elevateInstead) { manager.elevate() }
+ case .installer:
+ primary(Constants.UserInstalls.openInInstaller) { manager.openInInstaller() }
+ case .none:
+ EmptyView()
+ }
+ }
+ }
+ )
+ }
+
+ /// The approved copy, offered in place of the download the user went and found.
+ private func catalogBanner(_ suggestion: CatalogSuggestion) -> some View {
+ HStack(alignment: .top, spacing: 10) {
+ Image(systemName: "arrow.down.app.fill")
+ .font(.system(size: 15))
+ .foregroundStyle(.tint)
+
+ VStack(alignment: .leading, spacing: 2) {
+ Text(Constants.UserInstalls.availableInCatalog)
+ .font(.system(size: 12, weight: .semibold))
+
+ Text(String(format: Constants.UserInstalls.availableInCatalogDetail, suggestion.destinationName))
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ Spacer(minLength: 0)
+ }
+ .padding(11)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.accentColor.opacity(0.10))
+ )
+ }
+
+ private func failed(_ assessment: InstallerAssessment?, message: String) -> some View {
+ body(
+ assessment,
+ status: Status(
+ text: Constants.UserInstalls.failed,
+ symbol: "xmark.octagon.fill",
+ tint: .red
+ ),
+ detail: {
+ Text(message)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ },
+ actions: {
+ quiet(Constants.General.close) { manager.closeWindow() }
+ primary(Constants.UserInstalls.openInInstaller) { manager.openInInstaller() }
+ }
+ )
+ }
+
+ private func finished(_ assessment: InstallerAssessment) -> some View {
+ VStack(spacing: 0) {
+ Spacer()
+
+ Image(systemName: "checkmark.circle.fill")
+ .font(.system(size: 44))
+ .foregroundStyle(.green)
+ .padding(.bottom, 16)
+
+ Text(assessment.facts.displayName ?? assessment.facts.fileName)
+ .font(.system(size: 17, weight: .semibold))
+ .multilineTextAlignment(.center)
+
+ Text(Constants.UserInstalls.installed)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .padding(.top, 2)
+
+ Spacer()
+
+ actionBar {
+ primary(Constants.General.done) { manager.closeWindow() }
+ }
+ }
+ .padding(Layout.margin)
+ }
+
+ // MARK: Shared shape
+
+ private struct Status {
+ let text: String
+ let symbol: String
+ let tint: Color
+ }
+
+ /// Header, status, whatever detail the state has, then the actions pinned to the bottom.
+ ///
+ /// Every state that has something to show shares this, so the title, the status and the buttons sit
+ /// in exactly the same place as the window moves between them and nothing jumps.
+ private func body(
+ _ assessment: InstallerAssessment?,
+ status: Status,
+ @ViewBuilder detail: () -> Detail,
+ @ViewBuilder actions: () -> Actions
+ ) -> some View {
+ VStack(alignment: .leading, spacing: 0) {
+ if let assessment {
+ header(assessment)
+ .padding(.bottom, 16)
+ }
+
+ statusPill(status)
+
+ // One scroll region for everything between the status and the buttons, sized to the space
+ // that is left. A ScrollView has no height of its own, so nesting one next to a view that
+ // does means the scrolling one gets squeezed to nothing whenever the window is tight.
+ ScrollView {
+ detail()
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+ .scrollBounceBehavior(.basedOnSize)
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+ .padding(.top, 14)
+
+ actionBar(content: actions)
+ .padding(.top, 16)
+ }
+ .padding(Layout.margin)
+ }
+
+ private func header(_ assessment: InstallerAssessment) -> some View {
+ HStack(alignment: .center, spacing: 14) {
+ Image(systemName: assessment.facts.kind == .diskImage ? "app.dashed" : "shippingbox.fill")
+ .font(.system(size: 22, weight: .medium))
+ .foregroundStyle(.secondary)
+ .frame(width: 46, height: 46)
+ .background(
+ RoundedRectangle(cornerRadius: 11, style: .continuous)
+ .fill(Color.primary.opacity(0.06))
+ )
+
+ VStack(alignment: .leading, spacing: 3) {
+ Text(assessment.facts.displayName ?? assessment.facts.fileName)
+ .font(.system(size: 20, weight: .semibold))
+ .lineLimit(1)
+ .truncationMode(.tail)
+
+ Text(subtitle(for: assessment))
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 0)
+ }
+ }
+
+ private func subtitle(for assessment: InstallerAssessment) -> String {
+ let kind = assessment.facts.kind == .diskImage
+ ? Constants.UserInstalls.kindApplication
+ : Constants.UserInstalls.kindPackage
+
+ guard let version = assessment.facts.version else { return kind }
+
+ return "\(Constants.UserInstalls.version) \(version) · \(kind)"
+ }
+
+ private func statusPill(_ status: Status) -> some View {
+ HStack(spacing: 7) {
+ Image(systemName: status.symbol)
+ .font(.system(size: 12, weight: .semibold))
+
+ Text(status.text)
+ .font(.system(size: 12, weight: .medium))
+ .fixedSize(horizontal: false, vertical: true)
+ .multilineTextAlignment(.leading)
+ }
+ .foregroundStyle(status.tint)
+ .padding(.horizontal, 11)
+ .padding(.vertical, 7)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(status.tint.opacity(0.12))
+ )
+ }
+
+ /// What the helper checked. Reference material, so it is quiet and aligned rather than emphatic.
+ private func details(_ assessment: InstallerAssessment) -> some View {
+ Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 6) {
+ if let authority = assessment.facts.authority {
+ detailRow(Constants.UserInstalls.developer, authority)
+ }
+
+ detailRow(Constants.UserInstalls.verification, verification(assessment))
+
+ if let entry = assessment.matchedEntry {
+ detailRow(Constants.UserInstalls.allowedAs, entry)
+ }
+
+ // Where it lands. Worth showing even when nothing restricts it: "this browser also writes
+ // to /Library/LaunchDaemons" is the kind of thing somebody should be able to notice.
+ if !assessment.facts.payloadRoots.isEmpty {
+ detailRow(
+ Constants.UserInstalls.installsTo,
+ assessment.facts.payloadRoots.joined(separator: ", ")
+ )
+ }
+ }
+ }
+
+ private func verification(_ assessment: InstallerAssessment) -> String {
+ if assessment.matchMode == .strict {
+ return Constants.UserInstalls.verifiedByDigest
+ }
+
+ return assessment.facts.notarized
+ ? Constants.UserInstalls.verifiedNotarized
+ : Constants.UserInstalls.verifiedSignature
+ }
+
+ private func detailRow(_ label: String, _ value: String) -> some View {
+ GridRow {
+ Text(label)
+ .font(.system(size: 11))
+ .foregroundStyle(.tertiary)
+ .gridColumnAlignment(.leading)
+
+ Text(value)
+ .font(.system(size: 11))
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ .help(value)
+ }
+ }
+
+ private func reasons(_ reasons: [String]) -> some View {
+ VStack(alignment: .leading, spacing: 5) {
+ ForEach(reasons, id: \.self) { reason in
+ HStack(alignment: .firstTextBaseline, spacing: 7) {
+ Text("•")
+ .font(.system(size: 12))
+ .foregroundStyle(.tertiary)
+
+ Text(reason)
+ .font(.system(size: 12))
+ .foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ /// Everything an administrator needs to write an entry for this installer, in one selectable block.
+ ///
+ /// A refusal is a dead end otherwise: the person looking at it either has to write the profile
+ /// entry or has to send someone the details, and making them transcribe a digest off a screenshot
+ /// turns a five-second fix into a ticket.
+ private func identity(_ assessment: InstallerAssessment) -> some View {
+ VStack(alignment: .leading, spacing: 7) {
+ if let authority = assessment.facts.authority {
+ identityRow(Constants.UserInstalls.developer, authority, monospaced: false)
+ }
+
+ if let identifier = assessment.facts.identifiers.first {
+ identityRow(Constants.UserInstalls.identifier, identifier, monospaced: true)
+ }
+
+ identityRow(
+ "\(Constants.UserInstalls.fingerprint) \(assessment.facts.fileName)",
+ assessment.facts.sha256,
+ monospaced: true
+ )
+
+ if let leaf = assessment.facts.leafCertificateSHA256 {
+ identityRow(Constants.UserInstalls.certificate, leaf, monospaced: true)
+ }
+
+ // Where it would have installed. An administrator reading a refusal is deciding whether
+ // to allow this, and that decision is about what the installer does — not only about who
+ // signed it.
+ if !assessment.facts.payloadRoots.isEmpty {
+ identityRow(
+ Constants.UserInstalls.installsTo,
+ assessment.facts.payloadRoots.joined(separator: "\n"),
+ monospaced: true,
+ lineLimit: 6
+ )
+ }
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(10)
+ .background(
+ RoundedRectangle(cornerRadius: 8, style: .continuous)
+ .fill(Color.primary.opacity(0.04))
+ )
+ }
+
+ private func identityRow(_ label: String, _ value: String, monospaced: Bool, lineLimit: Int = 2) -> some View {
+ VStack(alignment: .leading, spacing: 2) {
+ Text(label)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundStyle(.tertiary)
+ .lineLimit(1)
+
+ Text(value)
+ .font(.system(size: 10, design: monospaced ? .monospaced : .default))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .lineLimit(lineLimit)
+ .truncationMode(.middle)
+ .help(value)
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
+ }
+
+ // MARK: Actions
+
+ /// The buttons, right aligned on one baseline with room for the hover effect to grow into.
+ private func actionBar(@ViewBuilder content: () -> Content) -> some View {
+ HStack(alignment: .center, spacing: 10) {
+ Spacer(minLength: 0)
+ content()
+ }
+ // ScButton scales to 1.1 on hover; without this it clips against the window edge.
+ .padding(.horizontal, 4)
+ .padding(.top, 4)
+ }
+
+ /// The one action worth taking, in the organisation's accent colour.
+ ///
+ /// No `maxWidth`: ScButton applies it as a hard frame, so a label longer than the number guessed
+ /// at the call site wraps onto a second line inside the button. Letting it hug its own text is
+ /// both tidier and one less number to get wrong when a string is translated.
+ private func primary(_ title: String, action: @escaping @MainActor () -> Void) -> some View {
+ ScButton(title, fontSize: 13) {
+ await MainActor.run { action() }
+ }
+ }
+
+ /// The way out. Deliberately unfilled: two accent-coloured buttons side by side give the user no
+ /// clue which one the window is actually for.
+ private func quiet(_ title: String, action: @escaping @MainActor () -> Void) -> some View {
+ Button {
+ action()
+ } label: {
+ Text(title)
+ .font(.system(size: 13))
+ .foregroundStyle(.primary)
+ // 16pt vertical to match ScButton's plain `.padding()`, so the two sit on one
+ // baseline at the same height. This is what made the row look ragged.
+ .padding(.horizontal, 20)
+ .padding(.vertical, 16)
+ .background(
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
+ .fill(Color.primary.opacity(0.07))
+ )
+ }
+ .buttonStyle(.plain)
+ }
+
+ private enum Layout {
+ static let margin: CGFloat = 26
+ }
+}
diff --git a/SupportCompanion/Views/WebView.swift b/SupportCompanion/Views/WebView.swift
deleted file mode 100644
index 166c9ab..0000000
--- a/SupportCompanion/Views/WebView.swift
+++ /dev/null
@@ -1,52 +0,0 @@
-//
-// WebView.swift
-// SupportCompanion
-//
-// Created by Tobias Almén on 2024-11-17.
-//
-
-import SwiftUI
-import WebKit
-
-struct WebViewWrapper: NSViewRepresentable {
- let webView: WKWebView
- let url: URL
- @Binding var isLoading: Bool
-
- func makeNSView(context: Context) -> WKWebView {
- webView.navigationDelegate = context.coordinator
- return webView
- }
-
- func updateNSView(_ nsView: WKWebView, context: Context) {}
-
- func makeCoordinator() -> Coordinator {
- Coordinator(self, isLoading: $isLoading)
- }
-
- class Coordinator: NSObject, WKNavigationDelegate {
- let parent: WebViewWrapper
- @Binding var isLoading: Bool
-
- init(_ parent: WebViewWrapper, isLoading: Binding) {
- self.parent = parent
- self._isLoading = isLoading
- }
-
- func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
- isLoading = true
- }
-
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- isLoading = false
- }
-
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
- isLoading = false
- }
-
- func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
- isLoading = false
- }
- }
-}
diff --git a/SupportCompanion/Views/WebViewContainer.swift b/SupportCompanion/Views/WebViewContainer.swift
index 112f6cc..68c33ed 100644
--- a/SupportCompanion/Views/WebViewContainer.swift
+++ b/SupportCompanion/Views/WebViewContainer.swift
@@ -10,72 +10,78 @@ import WebKit
struct WebViewContainer: View {
- @ObservedObject var state: WebViewState
+ var state: WebViewState
var body: some View {
- VStack {
+ VStack(spacing: 0) {
WebView(state: state)
-
- if state.isLoading {
- ProgressView(value: state.progress)
- .progressViewStyle(LinearProgressViewStyle())
- .padding()
- }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+
+ // Always present, hidden rather than removed. Inserting and removing a child as the page
+ // loads changes the stack's children in the middle of a layout pass driven by that same
+ // load, which is what turned a dependency cycle here into a crash in StackLayout rather
+ // than a warning.
+ ProgressView(value: state.progress)
+ .progressViewStyle(LinearProgressViewStyle())
+ .padding()
+ .opacity(state.isLoading ? 1 : 0)
+ .accessibilityHidden(!state.isLoading)
+ }
+ .onDisappear {
+ state.stopLoading()
}
}
}
-class WebViewStateManager: ObservableObject {
- @Published var webViewStates: [String: WebViewState] = [:]
+/// Plain cache, not observable: nothing in the UI reads the dictionary directly.
+final class WebViewStateManager {
+ // Stored synchronously to avoid the race condition where rapid
+ // body re-evaluations would create duplicate WebViewState instances for the
+ // same key before the async dispatch had a chance to store the first one.
+ // Nothing in the UI observes this dictionary directly — callers use the
+ // returned WebViewState (which is @Observable) for reactivity.
+ private var webViewStates: [String: WebViewState] = [:]
func getWebViewState(for id: String, url: URL) -> WebViewState {
if let existingState = webViewStates[id] {
return existingState
- } else {
- let newState = WebViewState(url: url)
- DispatchQueue.main.async {
- self.webViewStates[id] = newState
- }
- return newState
}
+ let newState = WebViewState(url: url)
+ webViewStates[id] = newState
+ return newState
}
}
struct WebView: NSViewRepresentable {
- @ObservedObject var state: WebViewState
-
+ var state: WebViewState
+
+ /// No coordinator, and no navigation delegate set here.
+ ///
+ /// `WebViewState` is already the web view's delegate, and its callbacks hop to the main queue
+ /// before touching `isLoading` so that a navigation event arriving mid-update cannot write
+ /// observable state while SwiftUI is evaluating the view that reads it. A coordinator here would
+ /// replace that delegate with one that writes synchronously, which is a dependency cycle:
+ /// the write invalidates the body currently being evaluated, and the graph never settles.
func makeNSView(context: Context) -> WKWebView {
- let webView = state.webView
- webView.navigationDelegate = context.coordinator
- return webView
+ state.webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {
// No updates needed as the `WKWebView` instance is persistent
}
- func makeCoordinator() -> Coordinator {
- Coordinator(state: state)
- }
-
- class Coordinator: NSObject, WKNavigationDelegate {
- private let state: WebViewState
-
- init(state: WebViewState) {
- self.state = state
- }
-
- func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
- state.isLoading = true
- }
-
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- state.isLoading = false
- }
-
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
- state.isLoading = false
- }
+ /// Take the size offered instead of letting SwiftUI ask the web view how big it wants to be.
+ ///
+ /// Without this, a representable is sized from its `NSView`'s fitting size — and a `WKWebView`'s
+ /// fitting size depends on the page it has loaded. The page then reflows to whatever size it is
+ /// given, which changes the fitting size again. That loop is a dependency cycle by construction,
+ /// and it only shows up on pages that load content, which is why the web pages crash and nothing
+ /// else does.
+ func sizeThatFits(_ proposal: ProposedViewSize, nsView: WKWebView, context: Context) -> CGSize? {
+ CGSize(
+ width: proposal.width ?? nsView.frame.width,
+ height: proposal.height ?? nsView.frame.height
+ )
}
}
diff --git a/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist b/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist
index 1acb8bc..dff8547 100644
--- a/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist
+++ b/SupportCompanion/pkgbuild/payload/Library/LaunchDaemons/com.github.macadmins.SupportCompanion.helper.plist
@@ -15,6 +15,8 @@
RunAtLoad
+ KeepAlive
+
Disabled
diff --git a/SupportCompanion/pkgbuild/scripts/postinstall b/SupportCompanion/pkgbuild/scripts/postinstall
index 64f4fb9..792421e 100755
--- a/SupportCompanion/pkgbuild/scripts/postinstall
+++ b/SupportCompanion/pkgbuild/scripts/postinstall
@@ -3,13 +3,17 @@
app_path="/Applications/SupportCompanion.app"
process_name="SupportCompanion"
launcha_name="com.github.macadmins.SupportCompanion.agent.plist"
-launch_daemon_path="/Library/LaunchDaemons/${launchd_name}"
launch_agent_path="/Library/LaunchAgents/${launcha_name}"
console_user=$(/usr/bin/stat -f "%Su" /dev/console)
console_user_uid=$(/usr/bin/id -u "$console_user")
# install helper
+#
+# The result is remembered rather than acted on immediately: the app should still be started and the
+# launch agent reloaded either way. But a failure here leaves the new app talking to an old helper,
+# which breaks every privileged operation, so the install must not report success.
"$app_path/Contents/Resources/helper_install.zsh"
+helper_install_status=$?
# kill the process if it's running
if [[ -n $( ps -ef | grep "${process_name}" | grep -v grep ) ]]; then
@@ -37,4 +41,9 @@ else
open -g -a "${app_path}"
fi
+if [[ "$helper_install_status" -ne 0 ]]; then
+ echo "Privileged helper installation failed; reporting the package install as failed." >&2
+ exit "$helper_install_status"
+fi
+
exit 0
diff --git a/SupportCompanion/pkgbuild/scripts/preinstall b/SupportCompanion/pkgbuild/scripts/preinstall
index a7b5fd9..e45bed0 100755
--- a/SupportCompanion/pkgbuild/scripts/preinstall
+++ b/SupportCompanion/pkgbuild/scripts/preinstall
@@ -7,7 +7,19 @@ if [[ -d "$v1AppPath" ]]; then
# Check if the uninstall script exists
uninstallScript="$v1AppPath/Contents/Resources/Uninstall.sh"
if [[ -f "$uninstallScript" ]]; then
- "$uninstallScript"
+ # This runs as root on every install, so only run it if it is still the script the 1.x
+ # installer left there: owned by root, and not writable by group or others. A file failing
+ # either test was put there by somebody else, and running it would hand them root.
+ owner=$(/usr/bin/stat -f '%u' "$uninstallScript")
+ mode=$(/usr/bin/stat -f '%Lp' "$uninstallScript")
+
+ if [[ "$owner" != "0" ]]; then
+ echo "Not running $uninstallScript: not owned by root."
+ elif (( 8#$mode & 8#022 )); then
+ echo "Not running $uninstallScript: writable by group or others."
+ else
+ "$uninstallScript"
+ fi
else
echo "Uninstall script not found at $uninstallScript."
fi
diff --git a/SupportCompanionCLI/main.swift b/SupportCompanionCLI/main.swift
index f42182f..d0b5513 100644
--- a/SupportCompanionCLI/main.swift
+++ b/SupportCompanionCLI/main.swift
@@ -5,7 +5,9 @@
// Created by Tobias Almén on 2024-12-03.
//
+import CoreWLAN
import Foundation
+import Network
struct SupportCompanionCLI {
static func main() async {
@@ -22,7 +24,7 @@ struct SupportCompanionCLI {
case "version":
printAppVersion()
case "reset":
- resetUserDefaults()
+ await resetUserDefaults()
print("UserDefaults have been reset.")
case "prefs":
printPreferencesStatus()
@@ -38,7 +40,7 @@ struct SupportCompanionCLI {
case "battery":
getBatteryInfo()
case "device":
- getDeviceInfo()
+ await getDeviceInfo()
case "storage":
getStorageInfo()
case "mdm":
@@ -66,45 +68,51 @@ struct SupportCompanionCLI {
}
static func getAppVersion() -> String? {
- guard let resourceBundleURL = Bundle.main.bundleURL
- .deletingLastPathComponent() // Resources folder
- .deletingLastPathComponent() // App bundle
- .appendingPathComponent("/Contents/Info.plist") as? URL else {
+ guard
+ let resourceBundleURL = Bundle.main.bundleURL
+ .deletingLastPathComponent() // Resources folder
+ .deletingLastPathComponent() // App bundle
+ .appendingPathComponent("/Contents/Info.plist") as? URL
+ else {
return nil
}
-
+
if let plistData = NSDictionary(contentsOf: resourceBundleURL) {
return plistData["CFBundleShortVersionString"] as? String
}
-
+
return nil
}
- static func resetUserDefaults() {
+ static func resetUserDefaults() async {
// Reinitialize preferences
- let preferences = Preferences()
- preferences.resetUserDefaults()
+ await MainActor.run {
+ let preferences = Preferences()
+ Task {
+ await preferences.resetUserDefaults()
+ }
+ }
}
static func printPreferencesStatus() {
let bundleIdentifier = "com.github.macadmins.SupportCompanion"
-
+
print("Current UserDefaults values for \(bundleIdentifier):")
-
+
// Execute the defaults read command
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/defaults")
process.arguments = ["read", bundleIdentifier]
-
+
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
-
+
do {
try process.run()
- process.waitUntilExit()
-
+ // Read before waiting: output larger than the pipe buffer (e.g. base64 logos) would otherwise deadlock
let data = pipe.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
if let output = String(data: data, encoding: .utf8), !output.isEmpty {
print(output)
} else {
@@ -116,18 +124,29 @@ struct SupportCompanionCLI {
}
func triggerAction(named actionName: String) {
- let url = "supportcompanion://run?action=\(actionName)"
if actionName.isEmpty {
print("Action name is empty. Please provide an action name.")
return
}
- print("""
- 🚀 Action Triggered
- -----------------------
- Action: \(actionName)
- Authentication might be required to run this action.
- """)
+ // Built with URLComponents so that names containing &, # or spaces reach the app intact
+ var components = URLComponents()
+ components.scheme = "supportcompanion"
+ components.host = "run"
+ components.queryItems = [URLQueryItem(name: "action", value: actionName)]
+
+ guard let url = components.url?.absoluteString else {
+ print("Could not build a URL for action '\(actionName)'.")
+ return
+ }
+ print(
+ """
+ 🚀 Action Triggered
+ -----------------------
+ Action: \(actionName)
+
+ Authentication might be required to run this action.
+ """)
let process = Process()
process.launchPath = "/usr/bin/open"
process.arguments = [url]
@@ -135,11 +154,11 @@ struct SupportCompanionCLI {
process.waitUntilExit()
}
- static func getDeviceInfo() {
+ static func getDeviceInfo() async {
let hostName = getHostName() ?? "Unknown"
let ram = getRAMSize()
let model = getModelName()
- let serial = getSerialNumber() ?? "Unknown"
+ let serial = getSerialNumber()
let processor = getCPUName() ?? "Unknown"
let ip = getAllIPAddresses().joined(separator: ", ")
let lastReboot = getLastRestartMinutes() ?? 0
@@ -147,33 +166,34 @@ struct SupportCompanionCLI {
let osBuild = getOSBuild()
let formattedLastRestart = formattedRebootContent(value: lastReboot)
-
- print("""
- 💻 Device Information
- -----------------------
- Hostname: \(hostName.uppercased())
- Model: \(model)
- Serial Number: \(serial)
- Processor: \(processor)
- Memory: \(ram)
- IP Address(es): \(ip)
- Last Reboot: \(formattedLastRestart)
- OS Version: \(osVersion)
- OS Build: \(osBuild)
- """)
+ print(
+ """
+ 💻 Device Information
+ -----------------------
+ Hostname: \(hostName.uppercased())
+ Model: \(model)
+ Serial Number: \(serial)
+ Processor: \(processor)
+ Memory: \(ram)
+ IP Address(es): \(ip)
+ Last Reboot: \(formattedLastRestart)
+ OS Version: \(osVersion)
+ OS Build: \(osBuild)
+ """)
}
-
+
static func getMDMInfo() async {
let MDMUrl = await getMDMUrl()
let MDMStatus = await getMDMStatusNoEnrollmentTime()
- print("""
- 🔒 MDM Information
- -----------------------
- Enrolled: \(MDMStatus["Enrolled"] ?? "Unknown")
- ABM: \(MDMStatus["ABM"] ?? "Unknown")
- MDM URL: \(MDMUrl)
- """)
+ print(
+ """
+ 🔒 MDM Information
+ -----------------------
+ Enrolled: \(MDMStatus["Enrolled"] ?? "Unknown")
+ ABM: \(MDMStatus["ABM"] ?? "Unknown")
+ MDM URL: \(MDMUrl)
+ """)
}
static func getStorageInfo() {
@@ -181,16 +201,18 @@ struct SupportCompanionCLI {
let storageUsage = getStorageUsagePercentage()
let fileVaultEnabled = isFileVaultEnabled()
- let progressBar = String(repeating: "▓", count: Int(storageUsage / 10)) +
- String(repeating: "░", count: 10 - Int(storageUsage / 10))
+ let progressBar =
+ String(repeating: "▓", count: Int(storageUsage / 10))
+ + String(repeating: "░", count: 10 - Int(storageUsage / 10))
- print("""
- 💾 Storage Information
- -----------------------
- Storage Name: \(storageName)
- Usage: \(progressBar) \(storageUsage)%
- FileVault: \(fileVaultEnabled ? "Enabled ✅" : "Disabled ❌")
- """)
+ print(
+ """
+ 💾 Storage Information
+ -----------------------
+ Storage Name: \(storageName)
+ Usage: \(progressBar) \(storageUsage)%
+ FileVault: \(fileVaultEnabled ? "Enabled ✅" : "Disabled ❌")
+ """)
}
static func getBatteryInfo() {
@@ -206,50 +228,53 @@ struct SupportCompanionCLI {
health = 0
}
let chargingStatus = isBatteryCharging()
- let timeRemaining = getBatteryTimeRemaining() ?? "Unknown"
-
- print("""
- 🔋 Battery Information
- -----------------------
- Health: \(health)% 🔋
- Cycle Count: \(String(getBatteryCycleCount() ?? 0))
- Temperature: \(String(format: "%.1f", batteryTemp ?? 0))\(usesMetric ? "°C" : "°F") 🌡️
- Charging Status: \(chargingStatus)
- Time Remaining: \(timeRemaining)
- """)
+ let timeRemaining = getBatteryTimeRemaining()
+
+ print(
+ """
+ 🔋 Battery Information
+ -----------------------
+ Health: \(health)% 🔋
+ Cycle Count: \(String(getBatteryCycleCount() ?? 0))
+ Temperature: \(String(format: "%.1f", batteryTemp ?? 0))\(usesMetric ? "°C" : "°F") 🌡️
+ Charging Status: \(chargingStatus)
+ Time Remaining: \(timeRemaining)
+ """)
}
-
+
static func getUserInfo() async {
let userInfoHelper = UserInfoHelper()
- do {
- let userInfo = try await userInfoHelper.fetchUserInfo()
- print("""
- 👤 User Information
- -----------------------
- Login: \(userInfo.login)
- Name: \(userInfo.name)
- Home Directory: \(userInfo.homeDir)
- Shell: \(userInfo.shell)
- Admin: \(userInfo.isAdmin ? "Yes" : "No")
- """)
- } catch {
- print("Failed to fetch user information: \(error.localizedDescription)")
- }
+ do {
+ let userInfo = try await userInfoHelper.fetchUserInfo()
+ print(
+ """
+ 👤 User Information
+ -----------------------
+ Login: \(userInfo.login)
+ Name: \(userInfo.name)
+ Home Directory: \(userInfo.homeDir)
+ Shell: \(userInfo.shell)
+ Admin: \(userInfo.isAdmin ? "Yes" : "No")
+ """)
+ } catch {
+ print("Failed to fetch user information: \(error.localizedDescription)")
+ }
}
static func getKerberosSSOInfo() async {
do {
let kerberosHelper = SSOInfoHelpers()
let kerberosInfo = try await kerberosHelper.fetchKerberosSSO()
- print("""
- 🎟 Kerberos SSO Information
- -----------------------
- Username: \(kerberosInfo.username)
- Realm: \(kerberosInfo.realm)
- Password Expires: \(kerberosInfo.expiryDays) days
- Last Password Change: \(kerberosInfo.lastSSOPasswordChangeDays) days
- Last Local Password Change: \(kerberosInfo.lastLocalPasswordChangeDays) days
- """)
+ print(
+ """
+ 🎟 Kerberos SSO Information
+ -----------------------
+ Username: \(kerberosInfo.username)
+ Realm: \(kerberosInfo.realm)
+ Password Expires: \(kerberosInfo.expiryDays) days
+ Last Password Change: \(kerberosInfo.lastSSOPasswordChangeDays) days
+ Last Local Password Change: \(kerberosInfo.lastLocalPasswordChangeDays) days
+ """)
} catch {
print("Failed to fetch Kerberos SSO information: \(error.localizedDescription)")
}
@@ -257,42 +282,44 @@ struct SupportCompanionCLI {
static func getPSSOInfo() async {
do {
- let ssoHelper = SSOInfoHelpers()
- let ssoInfo = try await ssoHelper.fetchPlatformSSO()
- print("""
- 🎟 Platform SSO Information
- -----------------------
- Login Frequency: \(ssoInfo.loginFrequency)
- Login Type: \(ssoInfo.loginType)
- New User Autorization Mode: \(ssoInfo.newUserAuthorizationMode)
- Registration Completed: \(ssoInfo.registrationCompleted)
- SDK Version: \(ssoInfo.sdkVersionString)
- Shared Device Keys: \(ssoInfo.sharedDeviceKeys)
- User Authorization Mode: \(ssoInfo.userAuthorizationMode)
- """)
+ let ssoHelper = SSOInfoHelpers()
+ let ssoInfo = try await ssoHelper.fetchPlatformSSO()
+ print(
+ """
+ 🎟 Platform SSO Information
+ -----------------------
+ Login Frequency: \(ssoInfo.loginFrequency)
+ Login Type: \(ssoInfo.loginType)
+ New User Autorization Mode: \(ssoInfo.newUserAuthorizationMode)
+ Registration Completed: \(ssoInfo.registrationCompleted)
+ SDK Version: \(ssoInfo.sdkVersionString)
+ Shared Device Keys: \(ssoInfo.sharedDeviceKeys)
+ User Authorization Mode: \(ssoInfo.userAuthorizationMode)
+ """)
} catch {
print("Failed to fetch Platform SSO information: \(error.localizedDescription)")
}
}
static func printUsage() {
- print("""
- Usage: SupportCompanionCLI
-
- Commands:
- version Output the app's version.
- reset Reset UserDefaults to default values.
- prefs Output the current user defaults preferences.
- action Trigger an action by name. Provide the action name as an argument.
- battery Output battery information.
- device Output device information.
- storage Output storage information.
- mdm Output MDM information.
- user Output user information.
- kerberos Output Kerberos SSO information.
- psso Output Platform SSO information.
- help Show this help message.
- """)
+ print(
+ """
+ Usage: SupportCompanionCLI
+
+ Commands:
+ version Output the app's version.
+ reset Reset UserDefaults to default values.
+ prefs Output the current user defaults preferences.
+ action Trigger an action by name. Provide the action name as an argument.
+ battery Output battery information.
+ device Output device information.
+ storage Output storage information.
+ mdm Output MDM information.
+ user Output user information.
+ kerberos Output Kerberos SSO information.
+ psso Output Platform SSO information.
+ help Show this help message.
+ """)
}
}
diff --git a/SupportCompanionTests/CatalogMatchingTests.swift b/SupportCompanionTests/CatalogMatchingTests.swift
new file mode 100644
index 0000000..2c9bc5a
--- /dev/null
+++ b/SupportCompanionTests/CatalogMatchingTests.swift
@@ -0,0 +1,60 @@
+//
+// CatalogMatchingTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+@Suite("Catalog matching")
+struct CatalogMatchingTests {
+
+ private let catalog = [
+ CatalogEntry(name: "Firefox", bundleIdentifier: "org.mozilla.firefox"),
+ CatalogEntry(name: "Google Chrome", bundleIdentifier: "com.google.Chrome"),
+ CatalogEntry(name: "Slack", bundleIdentifier: nil),
+ ]
+
+ private func facts(
+ fileName: String,
+ displayName: String? = nil,
+ identifiers: [String] = []
+ ) -> InstallerFacts {
+ InstallerFacts(kind: .diskImage, fileName: fileName, sha256: "", identifiers: identifiers, displayName: displayName)
+ }
+
+ @Test("Matches on bundle identifier regardless of what the file is called")
+ func byBundleIdentifier() {
+ let match = CatalogMatching.match(
+ facts(fileName: "fx-installer-final-2.dmg", identifiers: ["org.mozilla.firefox"]),
+ in: catalog
+ )
+ #expect(match?.name == "Firefox")
+ }
+
+ @Test("Matches on name once versions and punctuation are removed")
+ func byName() {
+ #expect(CatalogMatching.match(facts(fileName: "Firefox 156.0.dmg"), in: catalog)?.name == "Firefox")
+ #expect(CatalogMatching.match(facts(fileName: "Slack_V4.40.128.dmg"), in: catalog)?.name == "Slack")
+ #expect(CatalogMatching.match(facts(fileName: "x.pkg", displayName: "Google Chrome"), in: catalog)?.name == "Google Chrome")
+ }
+
+ @Test("Does not match a different application with a similar name")
+ func noNearMisses() {
+ #expect(CatalogMatching.match(facts(fileName: "Firefox Developer Edition.dmg"), in: catalog) == nil)
+ #expect(CatalogMatching.match(facts(fileName: "autopkg-2.9.0.pkg"), in: catalog) == nil)
+ #expect(CatalogMatching.match(facts(fileName: "Firefox.dmg"), in: []) == nil)
+ }
+
+ @Test("An identifier for something else does not drag in a name match")
+ func identifierWins() {
+ // The bundle identifier is authoritative, so a file named after one app carrying another's
+ // identifier resolves to the identifier.
+ let match = CatalogMatching.match(
+ facts(fileName: "Firefox 156.0.dmg", identifiers: ["com.google.Chrome"]),
+ in: catalog
+ )
+ #expect(match?.name == "Google Chrome")
+ }
+}
diff --git a/SupportCompanionTests/FleetDeviceManagerTests.swift b/SupportCompanionTests/FleetDeviceManagerTests.swift
new file mode 100644
index 0000000..e4e18d8
--- /dev/null
+++ b/SupportCompanionTests/FleetDeviceManagerTests.swift
@@ -0,0 +1,246 @@
+//
+// FleetDeviceManagerTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+private actor FakePolicyAPI: FleetDeviceAPI {
+ nonisolated let configurationProblem: String? = nil
+ private var results: [Result<[FleetPolicy], FleetError>]
+ private var refetchRequested: [Bool]
+ private(set) var refetchCalls = 0
+ /// What the ungated `/desktop` endpoint answers, or nil to make it fail like the gated calls.
+ var summaryFailingCount: Int?
+
+ init(_ results: [Result<[FleetPolicy], FleetError>], refetchRequested: [Bool] = []) {
+ self.results = results
+ self.refetchRequested = refetchRequested
+ }
+
+ func deviceHost() async throws -> FleetHost {
+ let policies = try (results.isEmpty ? .success([]) : results.removeFirst()).get()
+ let requested = refetchRequested.isEmpty ? false : refetchRequested.removeFirst()
+ return try host(policies: policies, refetchRequested: requested)
+ }
+
+ func desktopSummary() async throws -> FleetDesktopSummary {
+ guard let summaryFailingCount else { throw FleetError.network("no summary") }
+ let json: [String: Any] = ["failing_policies_count": summaryFailingCount, "self_service": true]
+ return try JSONDecoder.fleet.decode(FleetDesktopSummary.self, from: JSONSerialization.data(withJSONObject: json))
+ }
+
+ func setSummaryFailingCount(_ count: Int?) {
+ summaryFailingCount = count
+ }
+
+ func refetch() async throws {
+ refetchCalls += 1
+ }
+
+ private func host(policies: [FleetPolicy], refetchRequested: Bool) throws -> FleetHost {
+ let policyJSON = policies.map { ["id": $0.id, "name": $0.name, "response": $0.response ?? "", "critical": $0.critical ?? false] as [String: Any] }
+ let json: [String: Any] = ["id": 7, "refetch_requested": refetchRequested, "policies": policyJSON]
+ return try JSONDecoder.fleet.decode(FleetHost.self, from: JSONSerialization.data(withJSONObject: json))
+ }
+}
+
+private func policy(_ id: Int, _ name: String, _ response: String, critical: Bool = false) -> FleetPolicy {
+ let json: [String: Any] = ["id": id, "name": name, "response": response, "critical": critical]
+ return try! JSONDecoder.fleet.decode(FleetPolicy.self, from: JSONSerialization.data(withJSONObject: json))
+}
+
+@MainActor
+@Suite("Fleet device manager")
+struct FleetDeviceManagerTests {
+ private func makeDefaults() -> UserDefaults {
+ let name = "FleetDeviceManagerTests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: name)!
+ defaults.removePersistentDomain(forName: name)
+ return defaults
+ }
+
+ @Test("A failing policy is reported once, and again only after it passed")
+ func reportsOncePerFailure() async {
+ let fileVault = policy(1, "FileVault enabled", "fail")
+ let api = FakePolicyAPI([
+ .success([fileVault, policy(2, "Firewall", "pass")]),
+ .success([fileVault, policy(2, "Firewall", "pass")]),
+ .success([policy(1, "FileVault enabled", "pass")]),
+ .success([fileVault]),
+ ])
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+ var reported: [[Int]] = []
+ manager.onNewlyFailing = { reported.append($0.map(\.id)) }
+
+ for _ in 0..<4 { await manager.refresh() }
+
+ #expect(reported == [[1], [1]])
+ }
+
+ @Test("Reported failures survive a relaunch")
+ func persisted() async {
+ let defaults = makeDefaults()
+ let first = FleetDeviceManager(client: FakePolicyAPI([.success([policy(1, "FileVault", "fail")])]), defaults: defaults)
+ await first.refresh()
+
+ let relaunched = FleetDeviceManager(client: FakePolicyAPI([.success([policy(1, "FileVault", "fail")])]), defaults: defaults)
+ var reported = 0
+ relaunched.onNewlyFailing = { _ in reported += 1 }
+ await relaunched.refresh()
+
+ #expect(reported == 0)
+ }
+
+ @Test("Critical failures come first; policies that haven't run aren't counted")
+ func ordering() async {
+ let api = FakePolicyAPI([.success([
+ policy(1, "A", "fail"), policy(2, "B", "fail", critical: true), policy(3, "C", ""), policy(4, "D", "pass"),
+ ])])
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+
+ await manager.refresh()
+
+ #expect(manager.failingPolicies.map(\.id) == [2, 1])
+ #expect(manager.checkedPolicies.count == 3)
+ }
+
+ @Test("Signed out, the failing count comes from the ungated summary")
+ func failingCountSurvivesSignedOut() async {
+ let api = FakePolicyAPI([.failure(.ssoRequired)])
+ await api.setSummaryFailingCount(3)
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+
+ await manager.refresh()
+
+ #expect(manager.isSignedOut)
+ #expect(manager.policies.isEmpty)
+ // The whole point: the badge and the compliance card still say something true
+ #expect(manager.failingChecksCount == 3)
+ }
+
+ @Test("Signed in, the count comes from the policies, not the summary")
+ func signedInPrefersPolicies() async {
+ let api = FakePolicyAPI([.success([policy(1, "A", "fail"), policy(2, "B", "pass")])])
+ await api.setSummaryFailingCount(99)
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+
+ await manager.refresh()
+
+ #expect(!manager.isSignedOut)
+ #expect(manager.failingChecksCount == 1)
+ }
+
+ /// The summary is a separate request and can fail on its own; that must not invent a count of zero.
+ @Test("With no summary and no host, the count is unknown rather than zero")
+ func noSummaryMeansUnknown() async {
+ let api = FakePolicyAPI([.failure(.ssoRequired)])
+ await api.setSummaryFailingCount(nil)
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+
+ await manager.refresh()
+
+ #expect(manager.isSignedOut)
+ #expect(manager.failingChecksCount == nil)
+ }
+
+ @Test("A sign-in is reported once, not on every poll")
+ func signInReportedOncePerDay() async {
+ let api = FakePolicyAPI([.failure(.ssoRequired), .failure(.ssoRequired), .failure(.ssoRequired)])
+ await api.setSummaryFailingCount(0)
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+ var reports = 0
+ manager.onSignInRequired = { reports += 1 }
+
+ await manager.refresh()
+ await manager.refresh()
+ await manager.refresh()
+
+ // The session stays expired until the user acts, so every poll would otherwise nag
+ #expect(reports == 1)
+ }
+
+ /// Signing in clears the reminder, so the next sign-out is reported promptly rather than being
+ /// swallowed by the remainder of the day's interval.
+ @Test("Signing in resets the reminder")
+ func signingInResetsReminder() async {
+ let api = FakePolicyAPI([
+ .failure(.ssoRequired),
+ .success([policy(1, "A", "pass")]),
+ .failure(.ssoRequired),
+ ])
+ await api.setSummaryFailingCount(0)
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+ var reports = 0
+ manager.onSignInRequired = { reports += 1 }
+
+ await manager.refresh()
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(reports == 2)
+ }
+
+ @Test("SSO required keeps earlier policies")
+ func ssoRequired() async {
+ let api = FakePolicyAPI([.success([policy(1, "A", "fail")]), .failure(.ssoRequired)])
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults())
+
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(manager.loadState == .ssoRequired)
+ #expect(manager.policies.count == 1)
+ }
+
+ @Test("A second re-check while one is in flight doesn't send another request")
+ func refetchOnce() async throws {
+ let api = FakePolicyAPI([.success([]), .success([])], refetchRequested: [false, false])
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults(), refetchPollInterval: 60)
+
+ async let first: Void = manager.refetch()
+ async let second: Void = manager.refetch()
+ _ = try await (first, second)
+
+ #expect(await api.refetchCalls == 1)
+ }
+
+ @Test("Refetch waits until Fleet has the new details, then reports it finished")
+ func refetch() async throws {
+ let api = FakePolicyAPI([.success([]), .success([]), .success([])], refetchRequested: [false, true, false])
+ let manager = FleetDeviceManager(client: api, defaults: makeDefaults(), refetchPollInterval: 0.01)
+ await manager.refresh()
+ var finished = false
+ manager.onRefetchFinished = { finished = true }
+
+ try await manager.refetch()
+ #expect(manager.isRefetching)
+ #expect(await api.refetchCalls == 1)
+
+ for _ in 0..<200 where manager.isRefetching {
+ try await Task.sleep(for: .milliseconds(10))
+ }
+ #expect(!manager.isRefetching)
+ #expect(finished)
+ }
+}
+
+@Suite("Fleet host decoding")
+struct FleetHostDecodingTests {
+ @Test("Decodes host details with Go zero times, nanoseconds and fleet_name")
+ func decode() throws {
+ let json = """
+ {"host": {"id": 42, "hostname": "mac.local", "seen_time": "2026-09-17T10:11:12.123456789Z",
+ "detail_updated_at": "0001-01-01T00:00:00Z", "team_name": null, "fleet_name": "Staff",
+ "refetch_requested": false, "policies": [{"id": 1, "name": "FileVault", "response": "fail"}]}}
+ """
+ let host = try JSONDecoder.fleet.decode(FleetDeviceHostResponse.self, from: Data(json.utf8)).host
+ #expect(host.id == 42)
+ #expect(host.team == "Staff")
+ #expect(FleetHost.realDate(host.seenTime) != nil)
+ #expect(FleetHost.realDate(host.detailUpdatedAt) == nil)
+ #expect(host.policies?.first?.isFailing == true)
+ }
+}
diff --git a/SupportCompanionTests/FleetIconCatalogTests.swift b/SupportCompanionTests/FleetIconCatalogTests.swift
new file mode 100644
index 0000000..c33eea9
--- /dev/null
+++ b/SupportCompanionTests/FleetIconCatalogTests.swift
@@ -0,0 +1,54 @@
+//
+// FleetIconCatalogTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+@MainActor
+@Suite("Fleet icon catalog")
+struct FleetIconCatalogTests {
+ private let source = """
+ import { ISoftware } from "interfaces/software";
+ import Slack from "./Slack";
+ import Audacity from "./png/Audacity.png";
+ import GoogleChrome from "./png/Google-chrome.png";
+ import Arc from "./png/Arc.png";
+ import Archaeology from "./png/Archaeology.png";
+
+ export const SOFTWARE_NAME_TO_ICON_MAP = {
+ audacity: Audacity,
+ "google chrome": GoogleChrome,
+ slack: Slack,
+ arc: Arc,
+ "archaeology": Archaeology,
+ } as const;
+
+ export const SOFTWARE_SOURCE_TO_ICON_MAP = {
+ apps: Audacity,
+ };
+ """
+
+ @Test("Parses name keys that point at PNG icons only")
+ func parse() {
+ let files = FleetIconCatalog.parseIndex(source)
+ #expect(files == [
+ "audacity": "Audacity.png",
+ "google chrome": "Google-chrome.png",
+ "arc": "Arc.png",
+ "archaeology": "Archaeology.png",
+ ])
+ }
+
+ @Test("Matches like Fleet: whole name or a prefix followed by a space, longest key first")
+ func matching() {
+ let files = FleetIconCatalog.parseIndex(source)
+ #expect(FleetIconCatalog.match("google chrome", in: files) == "Google-chrome.png")
+ #expect(FleetIconCatalog.match("audacity 3", in: files) == "Audacity.png")
+ #expect(FleetIconCatalog.match("archaeology", in: files) == "Archaeology.png")
+ #expect(FleetIconCatalog.match("arcade", in: files) == nil)
+ #expect(FleetIconCatalog.match("slack", in: files) == nil)
+ }
+}
diff --git a/SupportCompanionTests/FleetModelsTests.swift b/SupportCompanionTests/FleetModelsTests.swift
new file mode 100644
index 0000000..0521579
--- /dev/null
+++ b/SupportCompanionTests/FleetModelsTests.swift
@@ -0,0 +1,155 @@
+//
+// FleetModelsTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+@Suite("Fleet models")
+struct FleetModelsTests {
+
+ private func decodeSoftware(_ json: String) throws -> FleetSoftwareListResponse {
+ try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8))
+ }
+
+ @Test("Decodes the example response from Fleet's REST API docs")
+ func decodesDocsExample() throws {
+ let response = try decodeSoftware(Fixtures.docsSoftwareList)
+ #expect(response.software.count == 1)
+ #expect(response.meta?.hasNextResults == false)
+
+ let chrome = try #require(response.software.first)
+ #expect(chrome.id == 936)
+ #expect(chrome.title == "Google Chrome") // empty display_name falls back to name
+ #expect(chrome.status == nil)
+ #expect(chrome.installedVersion == "149.0.7827.54")
+ #expect(chrome.availableVersion == "149.0.7827.54")
+ #expect(chrome.categories == ["Browsers"])
+ #expect(chrome.isInstalled)
+ #expect(!chrome.isUpdateAvailable)
+ #expect(chrome.canUninstall)
+ #expect(chrome.primaryAction == nil)
+ }
+
+ @Test("Installed title with a newer package offers an update")
+ func updateAvailable() throws {
+ let slack = try #require(try decodeSoftware(Fixtures.edgeCases).software.first { $0.id == 10 })
+ #expect(slack.title == "Slack for Work")
+ #expect(slack.installedVersion == "4.41.105") // highest of the installed versions
+ #expect(slack.availableVersion == "4.42.1")
+ #expect(slack.isUpdateAvailable)
+ #expect(slack.primaryAction == .update)
+ #expect(slack.installer?.lastInstall?.installUuid == "abc-123")
+ #expect(slack.installer?.lastInstall?.installedAt != nil) // fractional-second timestamp
+ }
+
+ @Test("Pending install offers no action")
+ func pendingInstall() throws {
+ let request = try #require(try decodeSoftware(Fixtures.edgeCases).software.first { $0.id == 11 })
+ #expect(request.status == .pendingInstall)
+ #expect(request.isPending)
+ #expect(!request.isInstalled)
+ #expect(request.primaryAction == nil)
+ #expect(!request.canUninstall)
+ }
+
+ @Test("Unknown status decodes, and App Store apps can't be uninstalled")
+ func unknownStatusAndAppStoreApp() throws {
+ let keynote = try #require(try decodeSoftware(Fixtures.edgeCases).software.first { $0.id == 12 })
+ #expect(keynote.status == .unknown)
+ #expect(keynote.softwarePackage == nil)
+ #expect(keynote.appStoreApp?.appStoreId == "409183694")
+ #expect(keynote.primaryAction == .install)
+ #expect(!keynote.canUninstall)
+ }
+
+ @Test("SSO-required error body is recognized")
+ func ssoRequiredError() throws {
+ let body = #"{"message":"Authentication required","errors":[{"name":"base","reason":"Single sign-on required"}],"uuid":"x","sso_required":true}"#
+ let error = try JSONDecoder.fleet.decode(FleetErrorResponse.self, from: Data(body.utf8))
+ #expect(error.ssoRequired == true)
+ #expect(error.summary == "Single sign-on required")
+ }
+
+ @Test("Version comparison is numeric", arguments: [
+ ("4.9", "4.10", true),
+ ("4.10", "4.9", false),
+ ("149.0.7827.54", "149.0.7827.54", false),
+ ("4.41.105", "4.42.1", true),
+ ("1.0", "1.0.1", true),
+ ])
+ func versionComparison(lhs: String, rhs: String, older: Bool) {
+ #expect(FleetVersion.isOlder(lhs, than: rhs) == older)
+ }
+}
+
+private enum Fixtures {
+ /// `GET /api/v1/fleet/device/:token/software` example from Fleet's REST API docs.
+ static let docsSoftwareList = """
+ {
+ "count": 1,
+ "software": [
+ {
+ "id": 936,
+ "name": "Google Chrome",
+ "icon_url": null,
+ "source": "apps",
+ "extension_for": "",
+ "status": null,
+ "installed_versions": [
+ {
+ "version": "149.0.7827.54",
+ "bundle_identifier": "com.google.Chrome",
+ "vulnerabilities": null,
+ "installed_paths": ["/Applications/Google Chrome.app"],
+ "signature_information": [
+ {
+ "installed_path": "/Applications/Google Chrome.app",
+ "team_identifier": "EQHXZ8M8AV",
+ "hash_sha256": "ce484e67c58b18313382e9fe2e225df52fb20b5f",
+ "executable_sha256": null,
+ "executable_path": null
+ }
+ ],
+ "last_opened_at": "2026-06-04T15:22:36Z"
+ }
+ ],
+ "display_name": "",
+ "software_package": {
+ "name": "GoogleChrome.pkg",
+ "automatic_install_policies": null,
+ "version": "149.0.7827.54",
+ "platform": "darwin",
+ "self_service": true,
+ "has_uninstall_script": true,
+ "last_install": null,
+ "last_uninstall": null,
+ "package_url": null,
+ "categories": ["Browsers"]
+ },
+ "app_store_app": null
+ }
+ ],
+ "meta": { "has_next_results": false, "has_previous_results": false }
+ }
+ """
+
+ static let edgeCases = """
+ {"count": 3, "software": [
+ {"id": 10, "name": "Slack", "display_name": "Slack for Work", "icon_url": null, "source": "apps", "status": "installed",
+ "installed_versions": [{"version": "4.41.105", "bundle_identifier": "com.tinyspeck.slackmacgap"}, {"version": "4.9.0"}],
+ "software_package": {"name": "Slack.pkg", "version": "4.42.1", "platform": "darwin", "self_service": true, "has_uninstall_script": true,
+ "last_install": {"install_uuid": "abc-123", "installed_at": "2026-09-01T10:20:30.123456Z"}, "last_uninstall": null, "categories": ["Communication"]},
+ "app_store_app": null},
+ {"id": 11, "name": "Request software", "display_name": "", "icon_url": null, "source": "", "status": "pending_install", "installed_versions": null,
+ "software_package": {"name": "request.sh", "version": "1.0", "platform": "darwin", "self_service": true,
+ "last_install": {"install_uuid": "def-456", "installed_at": "2026-09-17T09:00:00Z"}},
+ "app_store_app": null},
+ {"id": 12, "name": "Keynote", "display_name": "", "icon_url": null, "source": "apps", "status": "some_future_status", "installed_versions": [],
+ "software_package": null,
+ "app_store_app": {"app_store_id": "409183694", "name": "Keynote", "version": "14.4", "platform": "darwin", "self_service": true}}
+ ], "meta": {"has_next_results": true}}
+ """
+}
diff --git a/SupportCompanionTests/FleetSSOTests.swift b/SupportCompanionTests/FleetSSOTests.swift
new file mode 100644
index 0000000..76ec76f
--- /dev/null
+++ b/SupportCompanionTests/FleetSSOTests.swift
@@ -0,0 +1,116 @@
+//
+// FleetSSOTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+@Suite("Fleet Desktop SSO")
+struct FleetSSOTests {
+
+ private let server = URL(string: "https://fleet.example.com")!
+
+ private func response(setCookie: String?) -> HTTPURLResponse {
+ var fields: [String: String] = ["Content-Type": "application/json"]
+ if let setCookie { fields["Set-Cookie"] = setCookie }
+ return HTTPURLResponse(url: server, statusCode: 200, httpVersion: "HTTP/1.1", headerFields: fields)!
+ }
+
+ // MARK: - The handshake cookie
+
+ /// Exactly what Fleet 4.92 sends on POST /device/{token}/sso.
+ @Test func readsFleetsHandshakeCookie() throws {
+ let cookie = try #require(FleetClient.handshakeCookie(
+ from: response(setCookie: "__Host-FLEETSSOSESSIONID=abc123; Path=/; Max-Age=300; HttpOnly; Secure"),
+ server: server
+ ))
+
+ #expect(cookie.name == "__Host-FLEETSSOSESSIONID")
+ #expect(cookie.value == "abc123")
+ // __Host- cookies are pinned to the host and path, and the web view must be handed them that way
+ #expect(cookie.path == "/")
+ #expect(cookie.isSecure)
+ #expect(cookie.domain.contains("fleet.example.com"))
+ }
+
+ @Test func ignoresOtherCookies() {
+ let cookie = FleetClient.handshakeCookie(
+ from: response(setCookie: "__Host-FLEET_DESKTOP_SESSION=xyz; Path=/; Secure"),
+ server: server
+ )
+ #expect(cookie == nil)
+ }
+
+ @Test func toleratesNoCookieAtAll() {
+ #expect(FleetClient.handshakeCookie(from: response(setCookie: nil), server: server) == nil)
+ }
+
+ @Test func decodesTheIdentityProviderURL() throws {
+ let json = #"{"url":"https://login.microsoftonline.com/x/saml2?SAMLRequest=abc"}"#
+ let decoded = try JSONDecoder.fleet.decode(FleetSSOInitiationResponse.self, from: Data(json.utf8))
+ #expect(decoded.url == "https://login.microsoftonline.com/x/saml2?SAMLRequest=abc")
+ }
+
+ // MARK: - Fleet's failure redirect
+
+ @Test func readsTheErrorFleetRedirectsWith() throws {
+ let url = URL(string: "https://fleet.example.com/device/tok?sso_error=sso_disabled")!
+ #expect(FleetSSOController.ssoErrorReason(in: url) == "sso_disabled")
+ }
+
+ @Test func aPlainDevicePageIsNotAnError() {
+ let url = URL(string: "https://fleet.example.com/device/tok")!
+ #expect(FleetSSOController.ssoErrorReason(in: url) == nil)
+ }
+
+ /// An empty value is Fleet saying nothing, not Fleet reporting a failure with no reason.
+ @Test func anEmptyErrorIsNotAnError() {
+ let url = URL(string: "https://fleet.example.com/device/tok?sso_error=")!
+ #expect(FleetSSOController.ssoErrorReason(in: url) == nil)
+ }
+
+ // MARK: - Reusing a stored session
+
+ private func session(host: String, expiresAt: Date?) -> FleetSSOSessionStore.Session {
+ .init(cookie: "session-value", host: host, expiresAt: expiresAt)
+ }
+
+ @Test func aFreshSessionIsUsed() {
+ let stored = session(host: "fleet.example.com", expiresAt: Date().addingTimeInterval(5 * 24 * 3600))
+ #expect(stored.isUsable(on: "fleet.example.com"))
+ }
+
+ @Test func hostsAreComparedCaseInsensitively() {
+ let stored = session(host: "Fleet.Example.com", expiresAt: nil)
+ #expect(stored.isUsable(on: "fleet.example.com"))
+ }
+
+ /// A Mac repointed at another Fleet server signs in again rather than offering the old session.
+ @Test func aSessionFromAnotherServerIsRefused() {
+ let stored = session(host: "old.example.com", expiresAt: Date().addingTimeInterval(3600))
+ #expect(!stored.isUsable(on: "fleet.example.com"))
+ }
+
+ @Test func anExpiredSessionIsRefused() {
+ let stored = session(host: "fleet.example.com", expiresAt: Date().addingTimeInterval(-1))
+ #expect(!stored.isUsable(on: "fleet.example.com"))
+ }
+
+ /// Sending a session that expires mid-flight just earns an SSO prompt, so it's dropped early.
+ @Test func aSessionAboutToExpireIsRefused() {
+ let stored = session(host: "fleet.example.com", expiresAt: Date().addingTimeInterval(30))
+ #expect(!stored.isUsable(on: "fleet.example.com"))
+ }
+
+ @Test func aSessionSurvivesEncoding() throws {
+ let expiry = Date(timeIntervalSince1970: 1_800_000_000)
+ let stored = session(host: "fleet.example.com", expiresAt: expiry)
+ let decoded = try JSONDecoder().decode(
+ FleetSSOSessionStore.Session.self,
+ from: JSONEncoder().encode(stored)
+ )
+ #expect(decoded == stored)
+ }
+}
diff --git a/SupportCompanionTests/FleetSoftwareManagerTests.swift b/SupportCompanionTests/FleetSoftwareManagerTests.swift
new file mode 100644
index 0000000..6f3e51b
--- /dev/null
+++ b/SupportCompanionTests/FleetSoftwareManagerTests.swift
@@ -0,0 +1,480 @@
+//
+// FleetSoftwareManagerTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+/// Scripted stand-in for FleetClient.
+private actor FakeFleetAPI: FleetSoftwareAPI {
+ nonisolated let configurationProblem: String?
+ var softwareResults: [Result<[FleetSoftwareTitle], FleetError>]
+ var categoryResults: [Result<[FleetSoftwareCategory], FleetError>]
+ var installError: FleetError?
+ var installOutput = "Installer failed"
+ /// Thrown by the next install result request, then cleared.
+ var installResultError: FleetError?
+ private(set) var installResultCalls = 0
+ private(set) var softwareCalls = 0
+ private(set) var categoryCalls = 0
+ private(set) var installedIDs: [Int] = []
+ private(set) var uninstalledIDs: [Int] = []
+ private(set) var refetchCalls = 0
+
+ init(configurationProblem: String? = nil,
+ software: [Result<[FleetSoftwareTitle], FleetError>] = [],
+ categories: [Result<[FleetSoftwareCategory], FleetError>] = []) {
+ self.configurationProblem = configurationProblem
+ self.softwareResults = software
+ self.categoryResults = categories
+ }
+
+ func selfServiceSoftware() async throws -> [FleetSoftwareTitle] {
+ softwareCalls += 1
+ return try (softwareResults.isEmpty ? .success([]) : softwareResults.removeFirst()).get()
+ }
+
+ func selfServiceCategories() async throws -> [FleetSoftwareCategory] {
+ categoryCalls += 1
+ return try (categoryResults.isEmpty ? .success([]) : categoryResults.removeFirst()).get()
+ }
+
+ func install(titleID: Int) async throws {
+ if let installError { throw installError }
+ installedIDs.append(titleID)
+ }
+
+ func uninstall(titleID: Int) async throws {
+ uninstalledIDs.append(titleID)
+ }
+
+ func installResult(installUUID: String) async throws -> FleetInstallResult? {
+ installResultCalls += 1
+ if let installResultError {
+ self.installResultError = nil
+ throw installResultError
+ }
+ let object: [String: Any] = ["install_uuid": installUUID, "output": installOutput, "post_install_script_output": "exit 1"]
+ return try JSONDecoder.fleet.decode(FleetInstallResult.self, from: JSONSerialization.data(withJSONObject: object))
+ }
+
+ func setInstallOutput(_ output: String) {
+ installOutput = output
+ }
+
+ func failNextInstallResult(with error: FleetError) {
+ installResultError = error
+ }
+
+ func uninstallResult(executionID: String) async throws -> FleetScriptResult {
+ try JSONDecoder.fleet.decode(FleetScriptResult.self, from: Data(#"{"output": "removed"}"#.utf8))
+ }
+
+ func refetch() async throws {
+ refetchCalls += 1
+ }
+
+ func setInstallError(_ error: FleetError?) {
+ installError = error
+ }
+}
+
+private func title(_ id: Int, _ name: String, installed: String? = nil, available: String? = "1.0", status: String? = nil, displayName: String = "") -> FleetSoftwareTitle {
+ var json: [String: Any] = ["id": id, "name": name, "display_name": displayName]
+ if let status { json["status"] = status }
+ if let installed { json["installed_versions"] = [["version": installed]] }
+ if let available { json["software_package"] = ["version": available, "self_service": true] }
+ let data = try! JSONSerialization.data(withJSONObject: json)
+ return try! JSONDecoder.fleet.decode(FleetSoftwareTitle.self, from: data)
+}
+
+@MainActor
+@Suite("Fleet software manager")
+struct FleetSoftwareManagerTests {
+
+ @Test("Loads and sorts titles by name")
+ func loadsAndSorts() async {
+ let api = FakeFleetAPI(software: [.success([title(2, "zoom"), title(1, "Arc"), title(3, "Slack")])])
+ let manager = FleetSoftwareManager(client: api)
+
+ await manager.refresh()
+
+ #expect(manager.loadState == .loaded)
+ #expect(manager.titles.map(\.title) == ["Arc", "Slack", "zoom"])
+ #expect(manager.lastUpdated != nil)
+ }
+
+ @Test("Not configured when orbit or the server URL is missing, without calling Fleet")
+ func notConfigured() async {
+ let api = FakeFleetAPI(configurationProblem: "No Fleet server URL found")
+ let manager = FleetSoftwareManager(client: api)
+
+ await manager.refresh()
+
+ #expect(manager.loadState == .notConfigured)
+ #expect(manager.configurationProblem == "No Fleet server URL found")
+ #expect(await api.softwareCalls == 0)
+ }
+
+ @Test("SSO required is surfaced as its own state")
+ func ssoRequired() async {
+ let manager = FleetSoftwareManager(client: FakeFleetAPI(software: [.failure(.ssoRequired)]))
+
+ await manager.refresh()
+
+ #expect(manager.loadState == .ssoRequired)
+ }
+
+ @Test("A failed refresh keeps the previous catalog")
+ func failedRefreshKeepsTitles() async {
+ let api = FakeFleetAPI(software: [.success([title(1, "Arc")]), .failure(.server(status: 500, message: "boom"))])
+ let manager = FleetSoftwareManager(client: api)
+
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(manager.titles.map(\.title) == ["Arc"])
+ guard case .failed(let message) = manager.loadState else {
+ Issue.record("Expected a failed state, got \(manager.loadState)")
+ return
+ }
+ #expect(message == "boom")
+ }
+
+ @Test("Stops requesting categories after the server reports it doesn't have them")
+ func categoriesUnsupported() async {
+ let api = FakeFleetAPI(
+ software: [.success([title(1, "Arc")]), .success([title(1, "Arc")])],
+ categories: [.failure(.server(status: 404, message: "")), .success([])]
+ )
+ let manager = FleetSoftwareManager(client: api)
+
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(await api.categoryCalls == 1)
+ #expect(manager.loadState == .loaded)
+ }
+
+ @Test("Updates available lists only titles with a newer version")
+ func updatesAvailable() async {
+ let api = FakeFleetAPI(software: [.success([
+ title(1, "Arc", installed: "1.0", available: "1.1"),
+ title(2, "Slack", installed: "4.42", available: "4.42"),
+ title(3, "Zoom", installed: nil, available: "6.0"),
+ ])])
+ let manager = FleetSoftwareManager(client: api)
+
+ await manager.refresh()
+
+ #expect(manager.updatesAvailable.map(\.title) == ["Arc"])
+ }
+
+ @Test("An install is followed until Fleet reports it finished")
+ func installLifecycle() async {
+ let api = FakeFleetAPI(software: [
+ .success([title(1, "Arc", installed: nil, available: "1.0")]),
+ .success([title(1, "Arc", installed: nil, available: "1.0", status: "pending_install")]),
+ .success([title(1, "Arc", installed: "1.0", available: "1.0", status: "installed")]),
+ ])
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+ var finished: [(Int, FleetSoftwareTitle.Action, FleetSoftwareManager.ActionOutcome)] = []
+ manager.onActionFinished = { finished.append(($0.id, $1, $2)) }
+
+ await manager.refresh()
+ await manager.perform(.install, on: manager.titles[0])
+
+ #expect(await api.installedIDs == [1])
+ #expect(manager.isBusy(manager.titles[0]))
+
+ await manager.refresh()
+
+ #expect(manager.runningActions.isEmpty)
+ #expect(finished.count == 1)
+ #expect(finished.first?.1 == .install)
+ #expect(finished.first?.2 == .succeeded)
+ }
+
+ @Test("A title's status from before the action isn't taken as its result")
+ func waitsForPending() async {
+ let api = FakeFleetAPI(software: [
+ .success([title(1, "Arc", installed: "1.0", available: "1.1")]),
+ .success([title(1, "Arc", installed: "1.0", available: "1.1")]),
+ ])
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+ var finishedCount = 0
+ manager.onActionFinished = { _, _, _ in finishedCount += 1 }
+
+ await manager.refresh()
+ await manager.perform(.update, on: manager.titles[0])
+
+ #expect(manager.runningActions[1] == .update)
+ #expect(finishedCount == 0)
+ }
+
+ @Test("A failed install request is shown on the title and nothing is left running")
+ func installRequestFails() async {
+ let api = FakeFleetAPI(software: [.success([title(1, "Arc")])])
+ await api.setInstallError(.server(status: 422, message: "Software is not available"))
+ let manager = FleetSoftwareManager(client: api)
+
+ await manager.refresh()
+ await manager.perform(.install, on: manager.titles[0])
+
+ #expect(manager.runningActions.isEmpty)
+ #expect(manager.actionErrors[1] == "Software is not available")
+ }
+
+ @Test("Failure output combines the install result's outputs")
+ func failureOutput() async throws {
+ let json = #"{"id": 1, "name": "Arc", "status": "failed_install", "software_package": {"version": "1.0", "last_install": {"install_uuid": "abc-123"}}}"#
+ let failed = try JSONDecoder.fleet.decode(FleetSoftwareTitle.self, from: Data(json.utf8))
+ let manager = FleetSoftwareManager(client: FakeFleetAPI())
+
+ let output = try await manager.failureOutput(for: failed)
+
+ #expect(output == "Installer failed\n\nexit 1")
+ }
+}
+
+@Suite("Fleet button labels")
+struct FleetButtonLabelsTests {
+ @Test("Defaults when nothing is configured")
+ func defaults() {
+ let labels = FleetButtonLabels(nil)
+ #expect(labels.label(for: title(1, "Arc"), action: .install) == "Install")
+ #expect(labels.label(for: title(1, "Arc"), action: .uninstall) == "Uninstall")
+ }
+
+ @Test("A string replaces only the install label")
+ func stringValue() {
+ let labels = FleetButtonLabels(["Request software": "Request"])
+ let requested = title(7, "request_software", displayName: "Request software")
+ #expect(labels.label(for: requested, action: .install) == "Request")
+ #expect(labels.label(for: requested, action: .update) == "Update")
+ }
+
+ @Test("A dictionary sets each action, and the title id wins over the name")
+ func dictionaryAndIdPriority() {
+ let labels = FleetButtonLabels([
+ "Arc": ["Install": "By name"],
+ "1": ["Install": "Get", "Uninstall": "Remove", "Update": " "],
+ ])
+ #expect(labels.label(for: title(1, "Arc"), action: .install) == "Get")
+ #expect(labels.label(for: title(1, "Arc"), action: .uninstall) == "Remove")
+ #expect(labels.label(for: title(1, "Arc"), action: .update) == "Update")
+ }
+
+ @Test("Names match case-insensitively")
+ func caseInsensitive() {
+ let labels = FleetButtonLabels(["slack": "Request"])
+ #expect(labels.label(for: title(2, "Slack"), action: .install) == "Request")
+ }
+
+ @Test("Fleet's patch-when-closed skip counts as waiting for the app, without fetching output")
+ @MainActor func skippedInstall() async throws {
+ let json = """
+ {"software": [{"id": 1, "name": "Google Chrome", "status": "failed_install", "skipped_install": true,
+ "installed_versions": [{"version": "1.0"}],
+ "software_package": {"version": "2.0", "last_install": {"install_uuid": "u-1"}}}]}
+ """
+ let titles = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8)).software
+ let api = FakeFleetAPI(software: [.success(titles)])
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.refresh()
+
+ #expect(manager.isWaitingForAppToClose(manager.titles[0]))
+ #expect(manager.retryAction(for: manager.titles[0]) == .update)
+ #expect(await api.installResultCalls == 0)
+ }
+
+ @Test("A custom package's install output saying the app is open counts as waiting, checked once")
+ @MainActor func appOpenOutput() async throws {
+ let json = """
+ {"software": [{"id": 1, "name": "Zoom", "status": "failed_install",
+ "software_package": {"version": "6.0", "last_install": {"install_uuid": "u-2"}}}]}
+ """
+ let titles = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8)).software
+ let api = FakeFleetAPI(software: [.success(titles), .success(titles)])
+ await api.setInstallOutput("Installing software...\nFailed\nZoom must be closed before it can be updated.")
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(manager.isWaitingForAppToClose(manager.titles[0]))
+ #expect(await api.installResultCalls == 1)
+ }
+
+ @Test("An install output that couldn't be fetched is checked again on the next refresh")
+ @MainActor func appOpenOutputRetried() async throws {
+ let json = """
+ {"software": [{"id": 1, "name": "Zoom", "status": "failed_install",
+ "software_package": {"version": "6.0", "last_install": {"install_uuid": "u-4"}}}]}
+ """
+ let titles = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8)).software
+ let api = FakeFleetAPI(software: [.success(titles), .success(titles)])
+ await api.setInstallOutput("Zoom must be closed before it can be updated.")
+ await api.failNextInstallResult(with: .network("offline"))
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.refresh()
+ #expect(!manager.isWaitingForAppToClose(manager.titles[0]))
+
+ await manager.refresh()
+ #expect(manager.isWaitingForAppToClose(manager.titles[0]))
+ #expect(await api.installResultCalls == 2)
+ }
+
+ @Test("An install result Fleet no longer has isn't fetched again")
+ @MainActor func missingInstallResult() async throws {
+ let json = """
+ {"software": [{"id": 1, "name": "Zoom", "status": "failed_install",
+ "software_package": {"version": "6.0", "last_install": {"install_uuid": "u-5"}}}]}
+ """
+ let titles = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8)).software
+ let api = FakeFleetAPI(software: [.success(titles), .success(titles)])
+ await api.failNextInstallResult(with: .server(status: 404, message: ""))
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(!manager.isWaitingForAppToClose(manager.titles[0]))
+ #expect(await api.installResultCalls == 1)
+ }
+
+ @Test("A server error while fetching install output is retried, not given up on")
+ @MainActor func serverErrorRetried() async throws {
+ let json = """
+ {"software": [{"id": 1, "name": "Zoom", "status": "failed_install",
+ "software_package": {"version": "6.0", "last_install": {"install_uuid": "u-6"}}}]}
+ """
+ let titles = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8)).software
+ let api = FakeFleetAPI(software: [.success(titles), .success(titles)])
+ await api.setInstallOutput("Zoom must be closed before it can be updated.")
+ await api.failNextInstallResult(with: .server(status: 502, message: ""))
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.refresh()
+ await manager.refresh()
+
+ #expect(manager.isWaitingForAppToClose(manager.titles[0]))
+ }
+
+ @Test("A finished install isn't reported until its output was checked for an open app")
+ @MainActor func reportWaitsForOutputCheck() async throws {
+ let failedJSON = """
+ {"software": [{"id": 1, "name": "Zoom", "status": "failed_install",
+ "software_package": {"version": "6.0", "last_install": {"install_uuid": "u-7"}}}]}
+ """
+ let failed = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(failedJSON.utf8)).software
+ let api = FakeFleetAPI(software: [
+ .success([title(1, "Zoom", installed: nil, available: "6.0")]),
+ .success([title(1, "Zoom", installed: nil, available: "6.0", status: "pending_install")]),
+ .success(failed),
+ .success(failed),
+ ])
+ await api.setInstallOutput("Zoom must be closed before it can be updated.")
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+ var outcomes: [FleetSoftwareManager.ActionOutcome] = []
+ manager.onActionFinished = { outcomes.append($2) }
+
+ await manager.refresh()
+ await manager.perform(.install, on: manager.titles[0])
+ await api.failNextInstallResult(with: .network("offline"))
+ await manager.refresh()
+ #expect(outcomes.isEmpty)
+
+ await manager.refresh()
+ #expect(outcomes == [.appOpen])
+ }
+
+ @Test("Other install failures aren't mistaken for an open app")
+ @MainActor func ordinaryFailure() async throws {
+ let json = """
+ {"software": [{"id": 1, "name": "Zoom", "status": "failed_install",
+ "software_package": {"version": "6.0", "last_install": {"install_uuid": "u-3"}}}]}
+ """
+ let titles = try JSONDecoder.fleet.decode(FleetSoftwareListResponse.self, from: Data(json.utf8)).software
+ let manager = FleetSoftwareManager(client: FakeFleetAPI(software: [.success(titles)]), pendingPollInterval: 60)
+
+ await manager.refresh()
+
+ #expect(!manager.isWaitingForAppToClose(manager.titles[0]))
+ }
+
+ @Test("Update all requests an update for each title with one available")
+ @MainActor func updateAll() async {
+ let api = FakeFleetAPI(software: [.success([
+ title(1, "Arc", installed: "1.0", available: "1.1"),
+ title(2, "Slack", installed: "4.0", available: "4.0"),
+ title(3, "Zoom", installed: "5.0", available: "6.0"),
+ ])])
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.updateAll()
+
+ #expect(await api.installedIDs == [1, 3])
+ }
+
+ @Test("An update stays available while installing and until inventory shows the new version")
+ @MainActor func updateStaysUntilInventory() async {
+ let api = FakeFleetAPI(software: [
+ .success([title(1, "Arc", installed: "1.0", available: "1.1")]),
+ .success([title(1, "Arc", installed: "1.0", available: "1.1", status: "pending_install")]),
+ .success([title(1, "Arc", installed: "1.0", available: "1.1", status: "installed")]),
+ .success([title(1, "Arc", installed: "1.1", available: "1.1", status: "installed")]),
+ ])
+ let manager = FleetSoftwareManager(client: api, pendingPollInterval: 60)
+
+ await manager.refresh()
+ await manager.perform(.update, on: manager.titles[0])
+ #expect(manager.updatesAvailable.map(\.id) == [1]) // pending: still listed as an update
+
+ await manager.refresh()
+ #expect(manager.updatesAvailable.isEmpty) // Fleet says installed, inventory still shows 1.0
+ #expect(manager.installedVersionsAwaitingInventory[1] == "1.1")
+
+ await manager.refresh()
+ #expect(manager.updatesAvailable.isEmpty)
+ #expect(manager.installedVersionsAwaitingInventory.isEmpty) // inventory caught up
+ }
+
+ @Test("Reinstall is offered for up-to-date titles only, and can be relabeled")
+ func reinstall() {
+ let upToDate = title(1, "Arc", installed: "1.0", available: "1.0", status: "installed")
+ let outdated = title(2, "Slack", installed: "4.0", available: "4.1")
+ #expect(upToDate.canReinstall)
+ #expect(!outdated.canReinstall)
+ #expect(FleetButtonLabels(["Arc": ["Reinstall": "Repair"]]).label(for: upToDate, action: .reinstall) == "Repair")
+ }
+}
+
+@Suite("Fleet pending updates")
+struct PendingFleetUpdateTests {
+ @Test("Only titles with a newer version become pending updates, with stable ids")
+ func pendingUpdates() {
+ let outdated = title(42, "Slack", installed: "4.0", available: "4.1")
+ let update = PendingFleetUpdate(title: outdated)
+ #expect(update?.version == "4.0 → 4.1")
+ #expect(update?.id == PendingFleetUpdate(title: outdated)?.id)
+ #expect(PendingFleetUpdate(title: title(1, "Arc", installed: "1.0", available: "1.0")) == nil)
+ #expect(PendingFleetUpdate(title: title(3, "Zoom", installed: nil, available: "6.0")) == nil)
+ }
+}
+
+@Suite("Fleet recommended apps")
+struct FleetRecommendedAppsTests {
+ @Test("Recommended titles follow the preference order and match ids or names")
+ func order() {
+ let titles = [title(1, "Arc"), title(2, "Slack"), title(3, "zoom.us", displayName: "Zoom")]
+ let recommended = FleetRecommendedApps(keys: ["zoom", "1", "Missing", "arc"], sectionTitle: " ")
+ #expect(recommended.titles(from: titles).map(\.id) == [3, 1])
+ #expect(recommended.sectionTitle == "Recommended")
+ #expect(FleetRecommendedApps(keys: nil, sectionTitle: "Start here").sectionTitle == "Start here")
+ }
+}
diff --git a/SupportCompanionTests/InstallerPolicyTests.swift b/SupportCompanionTests/InstallerPolicyTests.swift
new file mode 100644
index 0000000..088a7a1
--- /dev/null
+++ b/SupportCompanionTests/InstallerPolicyTests.swift
@@ -0,0 +1,481 @@
+//
+// InstallerPolicyTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+@Suite("Installer allowlist")
+struct InstallerPolicyTests {
+
+ // MARK: Fixtures
+
+ private func entry(_ dictionary: [String: Any]) -> AllowedInstaller {
+ let (entry, problem) = AllowedInstaller.make(from: dictionary)
+ #expect(problem == nil)
+ return try! #require(entry)
+ }
+
+ private var chrome: AllowedInstaller {
+ entry([
+ "Name": "Google Chrome",
+ "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome",
+ "MinimumVersion": "141.0",
+ ])
+ }
+
+ private func facts(
+ kind: InstallerFacts.Kind = .package,
+ team: String? = "EQHXZ8M8AV",
+ identifiers: [String] = ["com.google.Chrome"],
+ version: String? = "141.0.1",
+ trusted: Bool = true,
+ notarized: Bool = true,
+ scripts: Bool = false,
+ remote: Bool = false,
+ sha256: String = String(repeating: "a", count: 64),
+ leafCertificateSHA256: String? = String(repeating: "c", count: 64),
+ payloadRoots: [String] = ["/Applications/Google Chrome.app"]
+ ) -> InstallerFacts {
+ InstallerFacts(
+ kind: kind,
+ fileName: "installer.pkg",
+ sha256: sha256,
+ teamID: team,
+ leafCertificateSHA256: leafCertificateSHA256,
+ signatureTrusted: trusted,
+ notarized: notarized,
+ identifiers: identifiers,
+ version: version,
+ hasScripts: scripts,
+ hasRemoteReferences: remote,
+ payloadRoots: payloadRoots
+ )
+ }
+
+ private func isAllowed(_ facts: InstallerFacts, _ allowlist: [AllowedInstaller]) -> Bool {
+ InstallerPolicy.evaluate(facts, against: allowlist).entry != nil
+ }
+
+ // MARK: Signature matching
+
+ @Test("Allows a package whose team, identifier and version all match")
+ func matches() {
+ #expect(isAllowed(facts(), [chrome]))
+ }
+
+ @Test("Refuses a package signed by another team")
+ func wrongTeam() {
+ #expect(!isAllowed(facts(team: "XXXXXXXXXX"), [chrome]))
+ }
+
+ @Test("Refuses a package the right team signed but that identifies as something else")
+ func wrongIdentifier() {
+ #expect(!isAllowed(facts(identifiers: ["com.example.Other"]), [chrome]))
+ }
+
+ @Test("Refuses a package whose signature does not chain to a trusted certificate")
+ func untrusted() {
+ #expect(!isAllowed(facts(trusted: false), [chrome]))
+ }
+
+ @Test("Refuses a package that is not notarized, unless the entry says otherwise")
+ func notarization() {
+ #expect(!isAllowed(facts(notarized: false), [chrome]))
+
+ let relaxed = entry([
+ "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome", "RequireNotarized": false,
+ ])
+ #expect(isAllowed(facts(notarized: false), [relaxed]))
+ }
+
+ @Test("Refuses a package carrying install scripts, unless the entry allows them")
+ func scripts() {
+ #expect(!isAllowed(facts(scripts: true), [chrome]))
+
+ let relaxed = entry([
+ "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome", "AllowScripts": true,
+ ])
+ #expect(isAllowed(facts(scripts: true), [relaxed]))
+ }
+
+ @Test("Refuses a package that downloads more packages while installing, whatever the entry says")
+ func remoteReferences() {
+ let permissive = entry([
+ "Name": "Anything", "TeamID": "EQHXZ8M8AV",
+ "AllowAnyIdentifier": true, "AllowScripts": true, "RequireNotarized": false,
+ ])
+ #expect(!isAllowed(facts(remote: true), [permissive]))
+ }
+
+ @Test("Refuses a version below the entry's minimum")
+ func versionFloor() {
+ #expect(!isAllowed(facts(version: "140.9"), [chrome]))
+ #expect(!isAllowed(facts(version: nil), [chrome]))
+ }
+
+ @Test("Refuses everything when nothing has been allowed")
+ func emptyAllowlist() {
+ #expect(!isAllowed(facts(), []))
+ }
+
+ @Test("Matches a disk image on its bundle identifier rather than a package identifier")
+ func diskImageIdentifier() {
+ let app = entry([
+ "Name": "Demo", "TeamID": "EQHXZ8M8AV", "BundleIdentifier": "com.example.Demo",
+ ])
+
+ #expect(isAllowed(facts(kind: .diskImage, identifiers: ["com.example.Demo"], version: nil), [app]))
+ // The same entry must not let the package through: the keys are per kind on purpose.
+ #expect(!isAllowed(facts(identifiers: ["com.example.Demo"], version: nil), [app]))
+ }
+
+ // MARK: Components
+
+ @Test("Refuses a package that installs a component the entry does not name")
+ func uncoveredComponent() {
+ let appOnly = entry([
+ "Name": "Alpha", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.example.alpha",
+ "RequireNotarized": false,
+ ])
+
+ let twoComponents = facts(identifiers: ["com.example.alpha", "com.example.beta"], version: nil, notarized: false)
+ #expect(!isAllowed(twoComponents, [appOnly]))
+
+ let both = entry([
+ "Name": "Alpha", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": ["com.example.alpha", "com.example.beta"],
+ "RequireNotarized": false,
+ ])
+ #expect(isAllowed(twoComponents, [both]))
+ }
+
+ @Test("Names the component it objected to")
+ func uncoveredComponentIsNamed() {
+ let appOnly = entry([
+ "Name": "Alpha", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.example.alpha",
+ "RequireNotarized": false,
+ ])
+
+ let decision = InstallerPolicy.evaluate(
+ facts(identifiers: ["com.example.alpha", "com.example.beta"], version: nil, notarized: false),
+ against: [appOnly]
+ )
+
+ #expect(decision.entry == nil)
+ #expect(decision.reasons.contains { $0.contains("com.example.beta") })
+ }
+
+ // MARK: Payload prefixes
+
+ @Test("Lets ordinary software install with nothing configured")
+ func ordinarySoftwareNeedsNoPayloadConfiguration() {
+ // These are what real installers do. Refusing them by default would make the feature
+ // unusable for most software and push administrators towards AllowUnrestrictedPayload.
+ for root in [
+ "/Applications/Google Chrome.app",
+ "/Library/Application Support/Example",
+ "/Library/LaunchDaemons/com.vendor.plist",
+ "/Library/LaunchAgents/com.vendor.plist",
+ "/usr/local/bin/tool",
+ "/usr/local/lib/libtool.dylib",
+ "/Library/Extensions/Vendor.kext",
+ "/Library/QuickLook/Vendor.qlgenerator",
+ "/etc/paths.d/vendor",
+ ] {
+ #expect(isAllowed(facts(payloadRoots: ["/Applications/Example.app", root]), [chrome]), "\(root) should install without configuration")
+ }
+ }
+
+ @Test("Still refuses the places that would take over the control itself")
+ func protectsTheControlItself() {
+ for root in [
+ "/Library/PrivilegedHelperTools/com.example.helper",
+ "/Library/Security/SecurityAgentPlugins/x.bundle",
+ "/Library/ScriptingAdditions/Example.osax",
+ "/etc/sudoers.d/example",
+ "/etc/pam.d/authorization",
+ "/private/etc/ssh/sshd_config",
+ "/var/db/dslocal/nodes/Default/users/admin.plist",
+ "/var/root/.ssh/authorized_keys",
+ ] {
+ #expect(!isAllowed(facts(payloadRoots: ["/Applications/Example.app", root]), [chrome]), "\(root) should be protected")
+ }
+ }
+
+ @Test("Protects the preferences that hold the allowlist itself")
+ func protectsItsOwnPolicy() {
+ // A package writing these rewrites the list that authorised it, plus EnforceAdminAllowlist
+ // and PermanentAdmins — one install becoming lasting control of the whole feature.
+ #expect(!isAllowed(facts(payloadRoots: ["/Library/Preferences/com.github.macadmins.SupportCompanion.plist"]), [chrome]))
+ #expect(!isAllowed(facts(payloadRoots: ["/Library/Managed Preferences/com.github.macadmins.SupportCompanion.plist"]), [chrome]))
+ }
+
+ @Test("Catches a payload root sitting above a protected prefix, not only inside one")
+ func denyListIsBidirectional() {
+ // payloadRoots truncate towards the root, so a root can be an ancestor of a protected path.
+ // "/Library" contains /Library/Preferences, "/etc" contains /etc/sudoers.d.
+ #expect(!isAllowed(facts(payloadRoots: ["/Library"]), [chrome]))
+ #expect(!isAllowed(facts(payloadRoots: ["/etc"]), [chrome]))
+
+ // ...while a prefix containing nothing protected is fine, which is the point of the change:
+ // /usr/local is ordinary software territory again.
+ #expect(isAllowed(facts(payloadRoots: ["/usr/local"]), [chrome]))
+ }
+
+ @Test("Checks every destination, however many there are")
+ func everyRootIsChecked() {
+ // A package with more destinations than the window shows, where the dangerous one sorts last.
+ var roots = (1...30).map { "/Applications/a\(String(format: "%02d", $0)).app" }
+ roots.append("/Library/PrivilegedHelperTools/com.example.helper")
+
+ #expect(!isAllowed(facts(payloadRoots: roots), [chrome]))
+
+ let decision = InstallerPolicy.evaluate(facts(payloadRoots: roots), against: [chrome])
+ #expect(decision.reasons.contains { $0.contains("PrivilegedHelperTools") })
+ }
+
+ @Test("Shortening for the window happens after the decision, not before it")
+ func shorteningIsDisplayOnly() {
+ let roots = (1...30).map { "/Applications/a\($0).app" }
+ let full = facts(payloadRoots: roots)
+
+ #expect(full.payloadRoots.count == 30)
+
+ let shown = full.shortenedForDisplay()
+ #expect(shown.payloadRoots.count == InstallerFacts.displayedPayloadRoots + 1)
+ #expect(shown.payloadRoots.last?.contains("more") == true)
+
+ // The shortened copy is for the sheet; the original is what was judged.
+ #expect(full.payloadRoots.count == 30)
+ }
+
+ @Test("Naming prefixes explicitly replaces the protection rather than adding to it")
+ func explicitPrefixesOverrideTheDenyList() {
+ let daemon = entry([
+ "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome",
+ "AllowedPayloadPrefixes": ["/Applications", "/Library/LaunchDaemons"],
+ ])
+
+ #expect(isAllowed(facts(payloadRoots: ["/Applications/Google Chrome.app", "/Library/LaunchDaemons/x.plist"]), [daemon]))
+ }
+
+ @Test("Unrestricted needs its own key, not a prefix of /")
+ func unrestrictedIsExplicit() {
+ let unrestricted = entry([
+ "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome",
+ "AllowUnrestrictedPayload": true,
+ ])
+
+ #expect(isAllowed(facts(payloadRoots: ["/etc/sudoers.d/x", "/var/root/x"]), [unrestricted]))
+ }
+
+ @Test("Refuses a package that writes outside the prefixes it was given")
+ func payloadPrefixes() {
+ let confined = entry([
+ "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome",
+ "AllowedPayloadPrefixes": ["/Applications"],
+ ])
+
+ #expect(isAllowed(facts(payloadRoots: ["/Applications/Google Chrome.app"]), [confined]))
+ #expect(!isAllowed(
+ facts(payloadRoots: ["/Applications/Google Chrome.app", "/Library/LaunchDaemons/x.plist"]),
+ [confined]
+ ))
+ }
+
+ @Test("Matches prefixes at path boundaries, not as plain text")
+ func prefixBoundaries() {
+ #expect(InstallerPolicy.isPath("/Applications/Firefox.app", under: "/Applications"))
+ #expect(InstallerPolicy.isPath("/Applications/Firefox.app", under: "/Applications/Firefox.app"))
+ #expect(!InstallerPolicy.isPath("/Applications/Firefox.app", under: "/Applications/Fire"))
+ #expect(!InstallerPolicy.isPath("/Library/LaunchDaemons", under: "/Applications"))
+ }
+
+ @Test("A digest pins the bytes, not where they land")
+ func strictStillChecksPayload() {
+ let strict = entry([
+ "Name": "In-house tool",
+ "SHA256": String(repeating: "a", count: 64),
+ "RequireNotarized": false,
+ "AllowedPayloadPrefixes": ["/Applications"],
+ ])
+
+ #expect(!isAllowed(facts(payloadRoots: ["/Library/LaunchDaemons/x.plist"]), [strict]))
+ }
+
+ // MARK: Certificate pin
+
+ @Test("Ignores the certificate unless an entry pins one")
+ func certificateNotPinned() {
+ #expect(isAllowed(facts(leafCertificateSHA256: String(repeating: "f", count: 64)), [chrome]))
+ #expect(isAllowed(facts(leafCertificateSHA256: nil), [chrome]))
+ }
+
+ @Test("Refuses a different certificate when one is pinned")
+ func certificatePinned() {
+ let pinned = entry([
+ "Name": "Google Chrome", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": "com.google.Chrome",
+ "LeafCertificateSHA256": String(repeating: "C", count: 64),
+ ])
+
+ // Case and spacing are how a digest gets pasted out of pkgutil, so both are tolerated.
+ #expect(isAllowed(facts(leafCertificateSHA256: String(repeating: "c", count: 64)), [pinned]))
+ #expect(!isAllowed(facts(leafCertificateSHA256: String(repeating: "d", count: 64)), [pinned]))
+ #expect(!isAllowed(facts(leafCertificateSHA256: nil), [pinned]))
+ }
+
+ @Test("Refuses a malformed certificate pin rather than ignoring it")
+ func certificatePinMustBeADigest() {
+ #expect(AllowedInstaller.make(from: [
+ "Name": "X", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.x",
+ "LeafCertificateSHA256": "not-a-digest",
+ ]).entry == nil)
+
+ // Spaces are stripped, since that is how pkgutil prints it.
+ #expect(AllowedInstaller.make(from: [
+ "Name": "X", "TeamID": "EQHXZ8M8AV", "PackageIdentifier": "com.x",
+ "LeafCertificateSHA256": "A3 D1 49 1B 4B 09 F8 D2 4E A8 32 D3 0C B3 5A 3A C0 32 08 3F 37 8A 6A 16 95 D0 CB C1 57 74 BA 99",
+ ]).entry?.leafCertificateSHA256 == "a3d1491b4b09f8d24ea832d30cb35a3ac032083f378a6a1695d0cbc15774ba99")
+ }
+
+ @Test("A pinned certificate still applies in strict mode")
+ func certificatePinInStrictMode() {
+ let strict = entry([
+ "Name": "In-house", "SHA256": String(repeating: "a", count: 64),
+ "RequireNotarized": false,
+ "LeafCertificateSHA256": String(repeating: "c", count: 64),
+ ])
+
+ #expect(isAllowed(facts(), [strict]))
+ #expect(!isAllowed(facts(leafCertificateSHA256: String(repeating: "d", count: 64)), [strict]))
+ }
+
+ // MARK: Strict matching
+
+ @Test("Allows only the exact digest in strict mode")
+ func strictDigest() {
+ let strict = entry([
+ "Name": "In-house tool",
+ "SHA256": String(repeating: "a", count: 64),
+ "RequireNotarized": false,
+ ])
+
+ #expect(isAllowed(facts(team: nil, identifiers: [], version: nil, trusted: false), [strict]))
+ #expect(!isAllowed(facts(sha256: String(repeating: "b", count: 64)), [strict]))
+ }
+
+ @Test("A digest does not excuse the entry's other conditions")
+ func strictStillChecksTheRest() {
+ let strict = entry(["Name": "In-house tool", "SHA256": String(repeating: "a", count: 64)])
+ #expect(!isAllowed(facts(notarized: false), [strict]))
+ }
+
+ // MARK: Entry validation
+
+ @Test("Drops an entry that could never match")
+ func invalidEntries() {
+ #expect(AllowedInstaller.make(from: ["Name": "X"]).entry == nil)
+ #expect(AllowedInstaller.make(from: ["TeamID": "EQHXZ8M8AV"]).entry == nil)
+ #expect(AllowedInstaller.make(from: ["Name": "X", "SHA256": "tooshort"]).entry == nil)
+ }
+
+ @Test("Drops a team-only entry unless it says AllowAnyIdentifier")
+ func teamWithoutIdentifier() {
+ #expect(AllowedInstaller.make(from: ["Name": "X", "TeamID": "EQHXZ8M8AV"]).entry == nil)
+
+ let vendor = entry(["Name": "X", "TeamID": "EQHXZ8M8AV", "AllowAnyIdentifier": true])
+ #expect(isAllowed(facts(identifiers: ["com.anything.At.All"], version: nil), [vendor]))
+ #expect(!isAllowed(facts(team: "OTHER00000", identifiers: ["com.anything.At.All"], version: nil), [vendor]))
+ }
+
+ @Test("Accepts several identifiers for one entry")
+ func identifierList() {
+ let either = entry([
+ "Name": "X", "TeamID": "EQHXZ8M8AV",
+ "PackageIdentifier": ["com.example.One", "com.example.Two"],
+ ])
+
+ #expect(isAllowed(facts(identifiers: ["com.example.Two"], version: nil), [either]))
+ #expect(!isAllowed(facts(identifiers: ["com.example.Three"], version: nil), [either]))
+ }
+
+ // MARK: Versions
+
+ @Test("Compares versions numerically rather than as text", arguments: [
+ ("10.10", "10.2", false),
+ ("10.2", "10.10", true),
+ ("141.0.1", "141", false),
+ ("141.0", "141.0", false),
+ ("2", "10", true),
+ ])
+ func versionComparison(version: String, minimum: String, older: Bool) {
+ #expect(InstallerPolicy.isVersion(version, olderThan: minimum) == older)
+ }
+
+ // MARK: Reasons
+
+ @Test("Explains the refusal using the entry that came closest")
+ func reasons() {
+ let unrelated = entry(["Name": "Firefox", "TeamID": "43AQ936H96", "PackageIdentifier": "org.mozilla.firefox"])
+ let decision = InstallerPolicy.evaluate(facts(version: "140.0"), against: [unrelated, chrome])
+
+ #expect(decision.entry == nil)
+ #expect(decision.reasons.count == 1)
+
+ // What the user sees never names the entry; the log keeps the full attribution.
+ #expect(!decision.reasons[0].contains("Google Chrome"))
+ #expect(decision.reasons[0].contains("141.0"))
+ #expect(decision.logReasons[0].hasPrefix("Google Chrome:"))
+ }
+
+ @Test("Tells the user nothing about the rest of the list when no entry was written for it")
+ func unlistedSaysNothingAboutOtherEntries() {
+ let unrelated = entry([
+ "Name": "Firefox", "TeamID": "43AQ936H96", "BundleIdentifier": "org.mozilla.firefox",
+ ])
+
+ let decision = InstallerPolicy.evaluate(
+ facts(identifiers: ["com.github.autopkg.autopkg"], version: nil, notarized: false),
+ against: [unrelated]
+ )
+
+ #expect(decision.entry == nil)
+ #expect(decision.reasons == [InstallerPolicy.unlisted])
+ #expect(!decision.reasons[0].contains("Firefox"))
+ #expect(!decision.reasons[0].contains("mozilla"))
+ }
+
+ // MARK: Transport
+
+ @Test("An assessment survives the trip over the connection as JSON")
+ func jsonRoundTrip() throws {
+ let assessment = InstallerAssessment(
+ token: UUID().uuidString,
+ facts: facts(),
+ isAllowed: true,
+ matchedEntry: "Google Chrome",
+ matchMode: .signature,
+ rejectionReasons: [],
+ fallback: .elevate,
+ requiresAuthentication: true
+ )
+
+ #expect(try InstallerAssessment.make(fromJSON: assessment.jsonString()) == assessment)
+ }
+
+ @Test("An unknown fallback falls back to handing the file to Installer.app")
+ func fallbackParsing() {
+ #expect(InstallerAssessment.Fallback(rawValue: "elevate") == .elevate)
+ #expect(InstallerAssessment.Fallback(rawValue: "nonsense") == nil)
+ }
+}
diff --git a/SupportCompanionTests/JamfUpdateMatchingTests.swift b/SupportCompanionTests/JamfUpdateMatchingTests.swift
new file mode 100644
index 0000000..df3c34f
--- /dev/null
+++ b/SupportCompanionTests/JamfUpdateMatchingTests.swift
@@ -0,0 +1,303 @@
+//
+// JamfUpdateMatchingTests.swift
+// SupportCompanionTests
+//
+
+import Foundation
+import Testing
+@testable import SupportCompanion
+
+@Suite("Jamf update matching")
+struct JamfUpdateMatchingTests {
+
+ // Dates from the Self Service store that produced the regression: the Zoom patch became available
+ // on 2026-09-24 with a deadline a week later, and the installed policy was last touched in 2025.
+ private let now = Date(timeIntervalSinceReferenceDate: 811_984_000) // 2026-09-25
+ private let available = Date(timeIntervalSinceReferenceDate: 811_937_502) // 2026-09-24
+ private let deadline = Date(timeIntervalSinceReferenceDate: 812_542_302) // 2026-10-01
+ private let longAgo = Date(timeIntervalSinceReferenceDate: 784_713_567) // 2025-11-13
+
+ private func policy(
+ _ name: String,
+ version: String? = nil,
+ installStatus: Int? = 4,
+ installedOrUpdated: Date? = nil
+ ) -> Policy {
+ Policy(
+ id: 1,
+ name: name,
+ policyVersion: version,
+ installedOrUpdated: installedOrUpdated,
+ installStatus: installStatus,
+ iconUrl: nil,
+ postInstallText: nil
+ )
+ }
+
+ private func patch(_ name: String, version: String, deadline: Date? = nil) -> Patch {
+ Patch(
+ id: 1,
+ name: name,
+ version: version,
+ availableDate: available,
+ deadlineDate: deadline,
+ buttonText: "Update",
+ installStatus: 0
+ )
+ }
+
+ private func updates(
+ _ policies: [Policy],
+ _ patches: [Patch],
+ installed: [String]
+ ) async -> [PendingJamfUpdate] {
+ await computeUpdates(policies: policies, patches: patches, now: now, installedApps: installed).0
+ }
+
+ // MARK: - The regression
+
+ @Test("Reports a patch whose installed app sits under a different policy name")
+ func prefersTheInstalledPolicy() async {
+ // Exactly what Jamf served: the patch title exists as its own uninstalled policy, while the
+ // app that is actually on the Mac is listed under "zoom.us".
+ let result = await updates(
+ [
+ policy("Zoom Client for Meetings", version: "7.2.2 (88465)", installStatus: 0),
+ policy("zoom.us", version: "7.0.5 (81138)", installStatus: 4, installedOrUpdated: longAgo),
+ ],
+ [patch("Zoom Client for Meetings", version: "7.2.2 (88465)", deadline: deadline)],
+ installed: ["zoom.us"]
+ )
+
+ #expect(result.count == 1)
+ #expect(result.first?.name == "Zoom Client for Meetings")
+ #expect(result.first?.policyName == "zoom.us")
+ }
+
+ @Test("An exact name match still wins when that policy's app is installed")
+ func exactMatchPreferred() async {
+ let result = await updates(
+ [
+ policy("Figma", version: "126.8.16", installedOrUpdated: longAgo),
+ policy("Figma Agent", version: "1.0", installedOrUpdated: longAgo),
+ ],
+ [patch("Figma", version: "127.0", deadline: deadline)],
+ installed: ["Figma", "Figma Agent"]
+ )
+
+ #expect(result.first?.policyName == "Figma")
+ }
+
+ // MARK: - Installed, according to the Mac rather than Jamf
+
+ @Test("Reports a patch for an app Jamf does not think it installed")
+ func installStatusZeroButOnDisk() async {
+ // Apparency: present in /Applications, but its policy reports installstatus 0.
+ let result = await updates(
+ [policy("Apparency", version: "3.1", installStatus: 0)],
+ [patch("Mothers Ruin Apparency", version: "3.2", deadline: deadline)],
+ installed: ["Apparency"]
+ )
+
+ #expect(result.count == 1)
+ #expect(result.first?.policyName == "Apparency")
+ }
+
+ @Test("Says nothing about an app Jamf believes is installed but the Mac does not have")
+ func installStatusFourButAbsent() async {
+ // Brave: the policy reports installstatus 4 and the bundle is gone. An "update" here would
+ // really be a fresh install of something nobody asked for.
+ let result = await updates(
+ [policy("Brave Browser", version: "154.1.96.59", installedOrUpdated: longAgo)],
+ [patch("Brave Browser", version: "155.0", deadline: deadline)],
+ installed: ["Figma", "zoom.us"]
+ )
+
+ #expect(result.isEmpty)
+ }
+
+ @Test("Says nothing about an app that is neither installed nor claimed to be")
+ func neitherSourceSaysInstalled() async {
+ let result = await updates(
+ [policy("GIMP", version: "3.0", installStatus: 0)],
+ [patch("GIMP", version: "3.1", deadline: deadline)],
+ installed: ["Figma"]
+ )
+
+ #expect(result.isEmpty)
+ }
+
+ @Test("Falls back to Jamf's record when the Mac cannot be read")
+ func emptyIndexFallsBackToInstallStatus() async {
+ // An empty index means the directory scan failed, not that the Mac has no applications, so
+ // dropping every patch would be the wrong reading of it.
+ let policies = [policy("Brave Browser", version: "154.1.96.59", installedOrUpdated: longAgo)]
+ let patches = [patch("Brave Browser", version: "155.0", deadline: deadline)]
+
+ #expect(await updates(policies, patches, installed: []).count == 1)
+ #expect(await updates(policies.map {
+ policy($0.name, version: $0.policyVersion, installStatus: 0)
+ }, patches, installed: []).isEmpty)
+ }
+
+ // MARK: - Counting
+
+ @Test("An installed app with no patch counts as up to date")
+ func upToDateCount() async {
+ let (results, updateCount, upToDateCount) = await computeUpdates(
+ policies: [
+ policy("zoom.us", version: "7.0.5 (81138)", installedOrUpdated: longAgo),
+ policy("Figma", version: "126.8.16", installedOrUpdated: longAgo),
+ policy("Brave Browser", version: "154.1", installedOrUpdated: longAgo),
+ ],
+ patches: [patch("Zoom Client for Meetings", version: "7.2.2 (88465)", deadline: deadline)],
+ now: now,
+ installedApps: ["zoom.us", "Figma"]
+ )
+
+ #expect(results.count == 1)
+ #expect(updateCount == 1)
+ // Figma is installed and unpatched; Brave is not on the Mac and is counted neither way.
+ #expect(upToDateCount == 1)
+ }
+
+ // MARK: - Name matching
+
+ @Test("Matches names across Jamf's spellings, and keeps distinct apps apart")
+ func nameSimilarityScores() {
+ // "client", "for" and "meetings" are stop words, so both sides reduce to "zoom".
+ #expect(nameSimilarity("Zoom Client for Meetings", "zoom.us") >= nameMatchThreshold)
+ #expect(nameSimilarity("Mothers Ruin Apparency", "Apparency") >= nameMatchThreshold)
+ #expect(nameSimilarity("Google Chrome", "Google Chrome") == 1.0)
+
+ #expect(nameSimilarity("Slack", "Zoom") == 0)
+ // Nothing usable on one side is not a match, rather than a vacuous one.
+ #expect(nameSimilarity("app", "Slack") == 0)
+
+ // Normalizing by the shorter name is what lets "zoom.us" answer for "Zoom Client for
+ // Meetings", and the price is that a one-token name matches any superset of it perfectly.
+ // Install evidence, not the score, is what keeps these apart.
+ #expect(nameSimilarity("Firefox", "Firefox Developer Edition") == 1.0)
+ }
+
+ @Test("A one-token name does not steal a patch from the app Jamf installed")
+ func supersetNamesSeparatedByEvidence() async {
+ let result = await updates(
+ [
+ policy("Firefox", installStatus: 0),
+ policy("Firefox Developer Edition", installedOrUpdated: longAgo),
+ ],
+ [patch("Firefox Developer Edition", version: "146.0", deadline: deadline)],
+ installed: ["Firefox Developer Edition"]
+ )
+
+ #expect(result.first?.policyName == "Firefox Developer Edition")
+ }
+
+ // MARK: - Pairing an update with the installed app
+
+ @Test("An update names the installed app, so the Apps page can pair them")
+ func updateNamesTheInstalledApp() async {
+ // The Apps page groups by `installedAppName`, and Self Service lists the installed app as
+ // "zoom.us" while the patch is called "Zoom Client for Meetings". Resolving to the listing
+ // instead would leave the update unpairable and its card without an Update button.
+ let result = await updates(
+ [
+ policy("Zoom Client for Meetings", version: "7.2.2 (88465)", installStatus: 0),
+ policy("zoom.us", version: "7.0.5 (81138)", installedOrUpdated: longAgo),
+ ],
+ [patch("Zoom Client for Meetings", version: "7.2.2 (88465)", deadline: deadline)],
+ installed: ["zoom.us"]
+ )
+
+ let update = try? #require(result.first)
+ #expect(update?.installedAppName == "zoom.us")
+ }
+
+ @Test("A patch with no usable tokens does not match an arbitrary policy")
+ func unmatchablePatchName() async {
+ let result = await updates(
+ [policy("Figma", version: "126.8.16", installedOrUpdated: longAgo)],
+ [patch("Update", version: "1.0", deadline: deadline)],
+ installed: ["Figma"]
+ )
+
+ #expect(result.isEmpty)
+ }
+}
+
+@Suite("Recently installed Jamf patches")
+struct RecentlyInstalledPatchesTests {
+
+ private let now = Date(timeIntervalSinceReferenceDate: 811_984_000)
+
+ private func update(_ patchId: Int, _ name: String, _ version: String) -> PendingJamfUpdate {
+ PendingJamfUpdate(
+ id: UUID(),
+ name: name,
+ version: version,
+ needsUpdate: true,
+ label: .dueNoDeadline,
+ details: "",
+ showInfoIcon: false,
+ dueBy: nil,
+ patchId: patchId,
+ policyName: name
+ )
+ }
+
+ @Test("An installed patch stops being offered while the store still lists it")
+ func hidesWhatWasJustInstalled() {
+ var tracked = RecentlyInstalledPatches()
+ let zoom = update(274, "Zoom Client for Meetings", "7.2.2 (88465)")
+
+ #expect(tracked.filtering([zoom], now: now).count == 1) // before installing
+
+ tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now)
+ #expect(tracked.filtering([zoom], now: now).isEmpty) // the store has not caught up yet
+ }
+
+ @Test("Forgets the patch once the store drops it")
+ func forgetsWhenStoreCatchesUp() {
+ var tracked = RecentlyInstalledPatches()
+ tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now)
+
+ // Self Service+ rewrote its store and the patch is gone.
+ #expect(tracked.filtering([], now: now).isEmpty)
+ #expect(tracked.isEmpty)
+ }
+
+ @Test("A newer patch for the same app is still offered")
+ func newerVersionIsNotHidden() {
+ var tracked = RecentlyInstalledPatches()
+ tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now)
+
+ let newer = update(274, "Zoom Client for Meetings", "7.3.0 (90001)")
+ #expect(tracked.filtering([newer], now: now).count == 1)
+ }
+
+ @Test("Comes back when the store never drops it, rather than hiding for good")
+ func reappearsAfterTheTimeout() {
+ var tracked = RecentlyInstalledPatches()
+ let zoom = update(274, "Zoom Client for Meetings", "7.2.2 (88465)")
+ tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now)
+
+ let justBefore = now.addingTimeInterval(RecentlyInstalledPatches.timeout - 1)
+ #expect(tracked.filtering([zoom], now: justBefore).isEmpty)
+
+ let after = now.addingTimeInterval(RecentlyInstalledPatches.timeout)
+ #expect(tracked.filtering([zoom], now: after).count == 1)
+ #expect(tracked.isEmpty) // and it is not hidden again next time either
+ }
+
+ @Test("Other apps' updates are untouched")
+ func leavesOtherUpdatesAlone() {
+ var tracked = RecentlyInstalledPatches()
+ tracked.record(patchId: 274, version: "7.2.2 (88465)", at: now)
+
+ let zoom = update(274, "Zoom Client for Meetings", "7.2.2 (88465)")
+ let figma = update(99, "Figma", "127.0")
+
+ #expect(tracked.filtering([zoom, figma], now: now).map(\.patchId) == [99])
+ }
+}
diff --git a/build.zsh b/build.zsh
index 9109a02..0838d1c 100755
--- a/build.zsh
+++ b/build.zsh
@@ -109,7 +109,7 @@ elif [ "$CONFIGURATION" = "Release" ]; then
SIGNING_IDENTITY_APP="Developer ID Application: Mac Admins Open Source (T4SK8ZXCXG)"
SIGNING_IDENTITY="Developer ID Installer: Mac Admins Open Source (T4SK8ZXCXG)"
KEYCHAIN_PROFILE="supportcompanion"
- XCODE_PATH="/Applications/Xcode_26.0.app"
+ XCODE_PATH="/Applications/Xcode_26.6.app"
TEAM_ID="T4SK8ZXCXG"
else
echo "No configuration set, exiting..."
@@ -170,7 +170,7 @@ OTHER_CODE_SIGN_FLAGS="--timestamp --options runtime --deep" \
DEVELOPMENT_TEAM="$TEAM_ID" \
ARCHS="arm64 x86_64" \
ONLY_ACTIVE_ARCH=NO \
--archivePath "$BUILDSDIR/SupportCompanion" >/dev/null 2>&1
+-archivePath "$BUILDSDIR/SupportCompanion"
check_exit_code "$?" "Error running xcodebuild"