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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: CI

on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test-and-build:
runs-on: macos-15
timeout-minutes: 20

steps:
- name: Check out source
uses: actions/checkout@v4

- name: Run tests
run: ./Scripts/run-tests.sh

- name: Build universal app
run: ./Scripts/build-app.sh

- name: Verify bundled architectures and signature
run: |
lipo dist/CleanMyScreen.app/Contents/MacOS/CleanMyScreen -verify_arch arm64 x86_64
codesign --verify --deep --strict dist/CleanMyScreen.app

- name: Build and verify DMGs
run: ./Scripts/build-dmgs.sh

- name: Upload unsigned build artifacts
uses: actions/upload-artifact@v4
with:
name: CleanMyScreen-${{ github.sha }}
path: |
dist/releases/*.dmg
dist/releases/SHA256SUMS.txt
if-no-files-found: error
retention-days: 14
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version: 6.2
// swift-tools-version: 6.1

import PackageDescription

Expand Down
68 changes: 43 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,52 @@
# CleanMyScreen

CleanMyScreen is a native macOS utility built with SwiftUI, AppKit, Core Graphics, and IOKit. The first release keeps every mode fully available without accounts, payments, trials, subscriptions, telemetry, or network access.
<p align="center">
<img src="Design/cleanmyscreen-logo-option-2.png" alt="CleanMyScreen sea-otter icon" width="180">
</p>

The app icon uses a sea-otter mascot in a forest-green palette. The mascot represents the cleaning workflow without implying that CleanMyScreen is a security boundary.
CleanMyScreen is a free, open-source macOS utility for temporarily blocking accidental keyboard and pointer input while you clean a display, play a video for a child, or let a pet sit near your Mac.

## Downloads
It is built natively with SwiftUI, AppKit, Core Graphics, and IOKit. There are no accounts, payments, trials, subscriptions, telemetry, or network access.

Release images are written to `dist/releases/` by `./Scripts/build-dmgs.sh`:
[Download the latest release](https://github.com/opensource-works/CleanMyMac/releases/latest) · [View the source](https://github.com/opensource-works/CleanMyMac) · [MIT License](LICENSE)

## Modes

| Mode | What stays visible | What is locked |
| --- | --- | --- |
| **Cleaning** | Pure-black overlays on one or every display | Keyboard and pointer input |
| **Pet / Kid** | Your current video, webpage, or application | Keyboard and trackpad/pointer input |
| **Selective** | Your current desktop | Keyboard, built-in trackpad, compatible external devices, or any combination |

Every session includes a three-second countdown, an optional automatic timeout, and a three-second Escape-key emergency unlock gesture. Ending a session restores overlays, the cursor, seized devices, and every brightness value the app was able to change.

![Cleaning mode](Design/implementation-cleaning-final.png)

<p align="center">
<img src="Design/implementation-pet-v1.png" alt="Pet and Kid mode" width="48%">
<img src="Design/implementation-selective-v1.png" alt="Selective mode" width="48%">
</p>

## Download and install

Choose the DMG that matches your Mac:

- `CleanMyScreen-arm64.dmg` for Apple Silicon Macs (M1, M2, M3, M4 and newer)
- `CleanMyScreen-x86_64.dmg` for Intel Macs

These local builds are ad-hoc signed. On first launch, macOS may require opening the app from Finder and confirming it in Privacy & Security. Input Monitoring is required only when a session locks input; the app provides a button that opens the exact System Settings pane.
Open the DMG and drag CleanMyScreen into Applications. The current public builds are ad-hoc signed rather than notarized with a paid Apple Developer ID. On first launch, macOS may require you to open the app from Finder and confirm it in **System Settings → Privacy & Security**.

## Modes
Published SHA-256 hashes are available in `SHA256SUMS.txt` on the release page.

## macOS permissions

- **Cleaning** covers one or every display in pure black, attempts to maximize supported display brightness, and temporarily blocks keyboard and pointer input.
- **Pet / Kid** leaves the current video or webpage visible while temporarily blocking accidental input.
- **Selective** can lock the keyboard, the built-in trackpad, compatible external input devices, or a combination.
macOS requires **Input Monitoring** before an app can observe and suppress input sent to other applications. CleanMyScreen asks for it only when a lock session needs it. If macOS no longer repeats its permission prompt, the app offers a button that opens the exact Input Monitoring pane. On configurations where the active event filter remains unavailable, it also links directly to Accessibility as a compatibility fallback.

Every session has a three-second countdown, a three-second Escape-key emergency unlock gesture, and an optional automatic timeout. Ending a session restores overlays, the cursor, seized devices, and any brightness values the app was able to change.
The app does not record, store, transmit, or inspect keystroke contents.

## Build and run
## Build from source

The repository can be built with Apple's Swift command-line tools:
Apple's Swift command-line tools are enough for local development:

```sh
./Scripts/run-tests.sh
Expand All @@ -32,25 +55,20 @@ The repository can be built with Apple's Swift command-line tools:
open dist/CleanMyScreen.app
```

`run-tests.sh` uses Swift Testing's bundled framework because Command Line Tools alone do not include the `xctest` launcher. With full Xcode installed, `swift test` can be used directly.

The build script produces a universal Apple Silicon + Intel application bundle and applies an ad-hoc local signature.
`build-app.sh` produces a universal Apple Silicon + Intel application and applies an ad-hoc local signature. `build-dmgs.sh` creates separate architecture-specific disk images, verifies them, and writes their SHA-256 hashes.

`build-dmgs.sh` thins that universal bundle into separate architecture-specific DMGs and verifies each disk image checksum.

Installing full Xcode is only required for an Xcode project workflow, Developer ID signing, notarization, or App Store distribution. The current brightness implementation uses a private CoreDisplay fallback, so an App Store build must replace or disable that capability before submission; the local/Developer ID build is the intended first distribution path.

## macOS system access
`run-tests.sh` uses Swift Testing's bundled framework because Command Line Tools alone do not include the `xctest` launcher. With full Xcode installed, `swift test` can be used directly.

macOS requires **Input Monitoring** approval before an app can observe and suppress input sent to other applications. CleanMyScreen requests that system control only when a lock session needs it. If macOS no longer repeats its own permission prompt, the app presents a button that opens the exact Input Monitoring pane. On configurations where an active event filter is still denied, the same flow links directly to Accessibility as a compatibility fallback. These are operating-system requirements, not account or product restrictions. The app does not record, store, transmit, or inspect keystroke contents.
Full Xcode is only required for an Xcode project workflow, Developer ID signing, notarization, or App Store distribution. The current brightness implementation uses a private CoreDisplay fallback, so an App Store build must replace or disable that capability before submission. Local and Developer ID distribution are the intended paths for this release.

## Hardware limits

- Brightness control is best-effort because macOS has no single public brightness API for every built-in and external display. Unsupported displays stay at their current brightness.
- Device-specific trackpad and external-device locking depends on what the connected HID hardware and macOS allow the app to seize. If the device cannot be safely isolated, the app reports the limitation and leaves input available.
- On a desktop Mac with no built-in keyboard, one external keyboard is deliberately kept available for the emergency unlock gesture. Devices connected after a session starts may remain available until that session is restarted.
- Brightness control is best-effort because macOS has no single public brightness API for every built-in and external display. Unsupported displays remain at their current brightness.
- Device-specific trackpad and external-device locking depends on what the connected HID hardware and macOS allow the app to seize. If a device cannot be safely isolated, the app reports the limitation and leaves that input available.
- On a desktop Mac with no built-in keyboard, one external keyboard is deliberately kept available for the emergency unlock gesture.
- Devices connected after a session starts may remain available until that session is restarted.
- CleanMyScreen is an accidental-input guard, not a login lock, parental-control boundary, or security product. Power, Touch ID, force quit, and other system-reserved operations remain available.

## License

CleanMyScreen is released under the MIT License. See [LICENSE](LICENSE).
CleanMyScreen is released under the [MIT License](LICENSE).
4 changes: 4 additions & 0 deletions Scripts/build-app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ fi
mkdir -p "$OUTPUT_ROOT"
ditto --noextattr --noqtn "$STAGED_APP" "$APP_ROOT"
xattr -cr "$APP_ROOT"
# File Provider folders can immediately reapply bundle-level metadata after a
# copy. Remove those two attributes explicitly before the final verification.
xattr -d com.apple.FinderInfo "$APP_ROOT" 2>/dev/null || true
xattr -d 'com.apple.fileprovider.fpfs#P' "$APP_ROOT" 2>/dev/null || true
codesign --verify --deep --strict "$APP_ROOT"

echo "$APP_ROOT"
6 changes: 6 additions & 0 deletions Scripts/build-dmgs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ for architecture in arm64 x86_64; do
hdiutil verify "$output_path" >/dev/null
echo "$output_path"
done

(
cd "$OUTPUT_ROOT"
shasum -a 256 CleanMyScreen-arm64.dmg CleanMyScreen-x86_64.dmg > SHA256SUMS.txt
)
echo "$OUTPUT_ROOT/SHA256SUMS.txt"
16 changes: 0 additions & 16 deletions Sources/CleanMyScreenKit/Services/DisplayOverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,6 @@ public final class DisplayOverlayController: NSObject, OverlayControlling {
)
}

isolated deinit {
hintFadeTimer?.invalidate()
hudDismissTimer?.invalidate()
hudCloseTimer?.invalidate()
closeCleaningWindows()
dismissTransientHUD()

if cursorIsHidden {
NSCursor.unhide()
}

// Removing the selector observer can retain a temporary reference to
// self, so keep it as the final operation in an isolated deinitializer.
NotificationCenter.default.removeObserver(self)
}

public func showCleaningOverlay(onAllDisplays: Bool, unlockHint: String) {
dismissTransientHUD()
closeCleaningWindows()
Expand Down
23 changes: 14 additions & 9 deletions Sources/CleanMyScreenKit/Services/SystemBrightnessController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import CoreGraphics
import Darwin
import Foundation

private final class DynamicFrameworkHandle: @unchecked Sendable {
let pointer: UnsafeMutableRawPointer

init(_ pointer: UnsafeMutableRawPointer) {
self.pointer = pointer
}

deinit {
dlclose(pointer)
}
}

/// Best-effort software brightness control for displays supported by
/// CoreDisplay. Unsupported displays are deliberately ignored.
@MainActor
Expand All @@ -15,7 +27,7 @@ public final class SystemBrightnessController: BrightnessControlling {
Float
) -> Int32

private var frameworkHandle: UnsafeMutableRawPointer?
private var frameworkHandle: DynamicFrameworkHandle?
private var getBrightnessFunction: GetBrightnessFunction?
private var setBrightnessFunction: SetBrightnessFunction?
private var originalBrightness: [CGDirectDisplayID: Float] = [:]
Expand Down Expand Up @@ -48,7 +60,7 @@ public final class SystemBrightnessController: BrightnessControlling {
return
}

frameworkHandle = handle
frameworkHandle = DynamicFrameworkHandle(handle)
getBrightnessFunction = unsafeBitCast(
getSymbol,
to: GetBrightnessFunction.self
Expand All @@ -59,13 +71,6 @@ public final class SystemBrightnessController: BrightnessControlling {
)
}

isolated deinit {
restore()
if let frameworkHandle {
dlclose(frameworkHandle)
}
}

public func maximizeSupportedDisplays() {
guard let getBrightnessFunction, let setBrightnessFunction else { return }

Expand Down
Loading