Skip to content
Closed
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
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ webpatch-fixtures:
rm -rf $$tmp; \
if [ $$fail -ne 0 ]; then exit 1; fi; \
echo "==> gate table and $$n webpatch fixtures: anchors intact, drift refused"
python3 dylib/tests/webpatch-renaming.py $(GATECHECK)

LAUNCH_SHELL := $(OUT_DIR)/launch-shell

Expand Down
47 changes: 31 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
# NotProton

NotProton enables the Steam Play experience from Linux Steam in the macOS Steam client.
Play Windows games from the native macOS Steam app, powered by CrossOver.

This is done by forcibly enabling the Steam Play functionality in macOS Steam (which is
present and inert) as well as by porting some components of Valve's Proton to macOS.
**[Download NotProton](https://github.com/NotProtonNot/NotProton/releases/latest)**

This tool is intended to be used with Steam Client 1788652215 or 1790121765 and **CrossOver Preview
2026082**. Both the FEX build and the Rosetta build are supported. The Rosetta build is
the recommended version, as the FEX one is an early state.
## Requirements

The macOS app itself is located in the ```app``` folder. The core logic is in ```dylib```.
```lsteamclient``` is a macOS port of Valve's lsteamclient. ```steam-shim```is a port of Valve's
steam-helper from Proton 9. ntdll-patch patches the copy of CrossOver that the app
makes/places in the ```~/Library/Application Support/notproton/runners/``` folder so that
lsteamclient is loaded.
* Apple Silicon and macOS 26 or later for this download.
* The native Steam app in `/Applications/Steam.app`, opened and signed in once.
* An activated, supported CrossOver build. NotProton is free; CrossOver is a separate paid product.
* Internet access for required Steam components and space for runtime copies and games.

This release is coming several days past when I wanted to release it, so the
documentation is quite sparse. Sorry about that, I'll improve it over the next day or
two.
Supported profiles are **CrossOver 26.3** (`26.3.0.39832`) and **Preview 20260821 / 20261006** (`27.0.0.40921` / `27.0.0.41069`). The app checks the actual binaries. Start with Rosetta; FEX is experimental.

Please read NOTICE for license information.
## Install

Please open issue reports with any issues. PRs are welcome and encouraged.
1. Unzip the download, copy **NotProton.app** to **Applications** and open it.
2. Follow the setup guide. If Steam access is blocked, open **System Settings → Privacy & Security → App Management** and enable NotProton. If it is missing, click **+** and add `/Applications/NotProton.app`. Return to the app and continue after the green check.
3. Quit games before installing the integration. Finish the runtime step, then click **Open Steam**.
4. In Steam, open a Windows game’s **Properties → Compatibility**. Enable **Force the use of a specific Steam Play compatibility tool**, then choose **CrossOver** in the dropdown and click **Play**.

That checkbox is in Steam’s **Compatibility** page. macOS Accessibility permission is not required to select CrossOver. Reopen setup from **Settings** at the bottom of the sidebar.

If macOS blocks the app and you trust the download, use the individual **Open Anyway** option under Privacy & Security. Keep Gatekeeper enabled.

## Help and limits

Use **Help** for setup and game issues. **Game environments** contains Windows components and prefix tools; **Backups** protects those environments before changes.

Game compatibility varies. Anti-cheat and some launchers can fail. Free Wine runtimes, existing CrossOver bottle imports and unlisted CrossOver builds are not supported. Native-only Mac library filtering is [not yet fixed](docs/ISSUE_48.md).

[Full installation steps](docs/INSTALLATION.md) · [Validation and build notes](docs/VALIDATION.md)

## Source and licenses

Build instructions and dependencies are recorded in the [app workflow](.github/workflows/app.yml) and Makefile. A full installer needs the staged runtime payload; a Swift UI build alone is insufficient.

NotProton and its dependencies retain their original licenses. See [NOTICE](NOTICE) and [LICENSE](LICENSE).
2 changes: 2 additions & 0 deletions app/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppBundlesUsageDescription</key>
<string>NotProton needs to update the Steam app to enable Windows games in native macOS Steam.</string>
<key>SUFeedURL</key>
<string>https://github.com/NotProtonNot/NotProton/releases/latest/download/appcast.xml</string>
<key>SUPublicEDKey</key>
Expand Down
3 changes: 3 additions & 0 deletions app/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ let hasTests = FileManager.default.fileExists(
// make app-payload stages the payload, so a checkout that has not been built does
// not have it. A declared resource that is missing is a build error, while an app
// missing the payload is a condition InstallPayload already reports.
// Stage payload before the first build: SwiftPM may cache manifest evaluation.
let hasPayload = FileManager.default.fileExists(
atPath: packageRoot.appendingPathComponent(payloadPath).path)
let package = Package(
name: "NotProtonApp",
defaultLocalization: "en",
platforms: [.macOS("26.0")],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", exact: "2.10.0"),
Expand All @@ -24,6 +26,7 @@ let package = Package(
dependencies: [.product(name: "Sparkle", package: "Sparkle")],
path: "Sources/NotProtonApp",
resources: (hasPayload ? [.copy("Resources/payload")] : []) + [
.process("Resources/en.lproj"),
.copy("Resources/payload.manifest"),
.copy("Resources/valve-packages.manifest"),
.copy("Resources/detour2.bin"),
Expand Down
10 changes: 6 additions & 4 deletions app/Sources/NotProtonApp/App.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,25 @@ struct NotProtonApp: App {
CommandGroup(after: .sidebar) {
Button("Status") { pane = .status }
.keyboardShortcut("1", modifiers: .command)
Button("Prefixes") { pane = .prefixes }
Button(GuideCopy.text("Game environments")) { pane = .prefixes }
.keyboardShortcut("2", modifiers: .command)
Button("Prefix Backups") { pane = .backups }
Button(GuideCopy.text("Backups")) { pane = .backups }
.keyboardShortcut("3", modifiers: .command)
Button(GuideCopy.text("Help")) { pane = .help }
.keyboardShortcut("4", modifiers: .command)

Divider()

Button("Refresh") {
Task {
switch pane {
case .status: await status.refresh()
case .status, .help, .settings: await status.refresh()
case .prefixes, .backups: await prefixes.load()
}
}
}
.keyboardShortcut("r", modifiers: .command)
.disabled(pane == .status ? !status.isIdle : prefixes.isLoading)
.disabled(pane == .status || pane == .help || pane == .settings ? !status.isIdle : prefixes.isLoading)

Divider()
}
Expand Down
36 changes: 36 additions & 0 deletions app/Sources/NotProtonApp/Model/HelpTopic.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation

struct HelpTopic: Identifiable, Sendable {
let id: String
let title: String
let body: String
let url: URL?

func matches(_ query: String) -> Bool {
let words = query.split(whereSeparator: \.isWhitespace)
let searchable = [title, body, GuideCopy.text(title), GuideCopy.text(body)].joined(separator: " ")
return words.allSatisfy { searchable.localizedStandardContains(String($0)) }
}

static let all: [HelpTopic] = [
.init(id: "first-game", title: "Where do I choose NotProton?",
body: "Use the macOS Steam app. In Library, open the game's Properties → Compatibility, enable Force the use of a specific Steam Play compatibility tool, then choose the installed NotProton / CrossOver tool from the dropdown. This is Steam's Compatibility page, not macOS Accessibility settings. Restart Steam after adding a runtime. You do not need Windows Steam in a separate bottle.",
url: URL(string: "https://github.com/NotProtonNot/NotProton")),
.init(id: "builds", title: "Why is my CrossOver version unsupported?",
body: "Stable and Preview are release channels, not compatibility guarantees. NotProton patches specific Wine binaries. Each exact build needs verified hashes and hook locations. Compare the build number in Status with the supported profiles. Do not rename an app to force support.", url: URL(string: "https://github.com/NotProtonNot/NotProton/issues/40")),
.init(id: "black-screen", title: "The game opens to a black screen",
body: "Allow the first launch to finish creating its game environment. If it stays black, quit the game normally and try again. Check the selected tool and change only one graphics option at a time in Steam's Compatibility page. A game-specific renderer or launcher problem may remain. Reinstalling NotProton is not a general fix for game compatibility.", url: URL(string: "https://github.com/NotProtonNot/NotProton/issues")),
.init(id: "controller", title: "My controller or keyboard does not work",
body: "Check the controller in macOS and in Steam's controller settings first. NotProton's Compatibility page includes game input options. Some games need different Steam Input settings. If Steam's macOS permission was denied, use Reset controller permission in Status, then restart Steam. Change one option at a time; no controller fix works for every game.", url: URL(string: "https://github.com/NotProtonNot/NotProton/issues/34")),
.init(id: "saves", title: "What is a prefix, and where are my saves?",
body: "A prefix is a game's Windows environment: registry, installed components and possibly save files. It appears in Game environments after the first Windows game launch. Back it up before rebuilding or experimenting. A prefix backup is not a backup of downloaded games or a guarantee of Steam Cloud sync. Existing CrossOver bottles cannot be migrated just by moving their folders.", url: nil),
.init(id: "updates", title: "Steam updated and compatibility disappeared",
body: "Refresh Status to inspect the integration. Use an updated NotProton build when Steam changes its interface. Blocking Steam client updates is optional and also delays Steam fixes; it does not stop game updates. An older app cannot safely repair a newer NotProton installation. Keep your own save backups.", url: nil),
.init(id: "dependencies", title: "VCRuntime is missing or a launcher opens",
body: "Additional Windows components belong in the affected game's environment. Use Game environments → Tools → Run Program to run a trusted component installer in that prefix. Avoid random DLL download sites. Ubisoft and other third-party launchers can still have compatibility issues; do not install a second Steam client as a blanket workaround.", url: URL(string: "https://github.com/NotProtonNot/NotProton/issues/42")),
.init(id: "anticheat", title: "Will online games and anti-cheat work?",
body: "NotProton does not make every anti-cheat system compatible with Wine. VAC is not supported by this integration; a game opening does not prove protected multiplayer works. Check the game's requirements and current reports before buying it for this setup.", url: URL(string: "https://github.com/NotProtonNot/NotProton/issues/43")),
.init(id: "mods", title: "Can I run a custom EXE or install mods?",
body: "Select the game's environment and use Tools → Run Program to run a trusted EXE there. This does not replace Steam's default Play executable. Custom launch options and mods are game-specific; back up the prefix before changing its components.", url: URL(string: "https://github.com/NotProtonNot/NotProton/issues/17")),
]
}
17 changes: 17 additions & 0 deletions app/Sources/NotProtonApp/Model/PayloadState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ struct PayloadState: Sendable {
var legacyCompatExpected: Int
var manifestProblem: String?

// SteamInstaller stages built bridge files and helpers. Valve binaries and
// patched ntdlls belong to runner setup, and cannot be repaired by Install.
var isSteamComplete: Bool {
manifestProblem == nil && missing(origin: .built).isEmpty
&& overlayShimPresent && iconmakerPresent && appinfoPresent && signatureDatabase != nil
}

var steamRepairDetail: String {
if let manifestProblem { return manifestProblem }
var names = missing(origin: .built).map(\.path)
if !overlayShimPresent { names.append("overlay-shim.dylib") }
if !iconmakerPresent { names.append("iconmaker") }
if !appinfoPresent { names.append("appinfo") }
if signatureDatabase == nil { names.append("Steam signatures") }
return "Missing: \(names.joined(separator: ", ")). Install to restore these files for your macOS user."
}

var isComplete: Bool {
manifestProblem == nil && missing.isEmpty && overlayShimPresent && iconmakerPresent
&& appinfoPresent && signatureDatabase != nil
Expand Down
84 changes: 84 additions & 0 deletions app/Sources/NotProtonApp/Model/SetupGuide.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation

// Completion comes from inspected files, not from a wizard's Next button.
struct SetupGuide: Sendable {
enum Step: Int, CaseIterable, Identifiable {
case requirements, permissions, integration, runtime, play
var id: Self { self }
var title: String {
switch self {
case .requirements: "Check requirements"
case .permissions: "Prepare macOS permissions"
case .integration: "Install Steam integration"
case .runtime: "Set up a runtime"
case .play: "Start your first game"
}
}
}

let requirementsReady: Bool
let runtimeAvailable: Bool
let integrationReady: Bool
let runtimeReady: Bool
let requirementsDetail: String
let integrationDetail: String

var isReady: Bool { requirementsReady && integrationReady && runtimeReady }
var next: Step {
if !requirementsReady { return .requirements }
if !integrationReady { return .integration }
if !runtimeReady { return .runtime }
return .play
}

func isComplete(_ step: Step) -> Bool {
switch step {
case .requirements: requirementsReady
case .permissions: false
case .integration: integrationReady
case .runtime: runtimeReady
// A running process alone does not prove that a game works.
case .play: false
}
}

init(_ snapshot: StatusSnapshot) {
let hasRuntime: Bool
if case .ready(let builds) = snapshot.runner { hasRuntime = !builds.isEmpty }
else { hasRuntime = false }
let hasSource = snapshot.crossOver.contains { install in
install.isUsable && snapshot.crossOverLicense[install.id]?.licensed != false
}
let hasSteam = snapshot.steam != .steamMissing
runtimeAvailable = hasRuntime || hasSource
requirementsReady = hasSteam && (hasRuntime || hasSource)
requirementsDetail = !hasSteam ? "Install the macOS Steam app in Applications and sign in once."
: !(hasRuntime || hasSource) ? "Choose a supported CrossOver build and activate it in CrossOver."
: "Steam and a supported runtime are available."

let installed: Bool
if case .installed = snapshot.steam { installed = true } else { installed = false }
integrationReady = installed && snapshot.payload.isSteamComplete && snapshot.installContent == .current
integrationDetail = switch snapshot.installContent {
case .newerInstalled: "A newer build is installed. Open the app that installed it."
case .unavailable: "Installed files could not be checked. See the status details before continuing."
case .update, .unrecorded: "This app includes updated integration files. Install them when no game is running."
case .repair: "Some installed files need repair. See the status details below."
case .unchecked: "Refresh to check the installed files."
case .current: integrationReady ? "Steam integration is installed and its files have been checked."
: "Install the integration. Steam may close; quit your games first."
}
runtimeReady = hasRuntime && snapshot.payload.isComplete
}
}

enum GuideCopy {
static func text(_ key: String) -> String {
Bundle.module.localizedString(forKey: key, value: key, table: "Guidance")
}
}

enum GuideMotion {
// Native controls own press/focus feedback. Only the changing content moves.
static let duration = 0.22
}
34 changes: 31 additions & 3 deletions app/Sources/NotProtonApp/Model/SteamInstaller.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 'Install' logic for the Steam patch componen

import Foundation
import Darwin

enum InstallPhase: Sendable {
case checkingPayload
Expand Down Expand Up @@ -278,9 +279,9 @@ enum SteamInstaller {

static func assertBundleIsWritable(_ app: URL) throws {
let directory = app.appending(path: "Contents/MacOS")
let probe = directory.appending(path: ".notproton-write-probe")
let probe = directory.appending(path: ".notproton-write-probe-\(UUID().uuidString)")
do {
try Data("probe".utf8).write(to: probe)
try Data("probe".utf8).write(to: probe, options: .withoutOverwriting)
try FileManager.default.removeItem(at: probe)
} catch let error as NSError where error.code == NSFileWriteNoPermissionError {
throw WriteRefused(path: directory.path(percentEncoded: false))
Expand Down Expand Up @@ -340,7 +341,11 @@ enum SteamInstaller {

static func adHocSign(_ url: URL, step: String = step) throws {
let path = url.path(percentEncoded: false)
let result = try Shell.run("/usr/bin/codesign", ["-f", "-s", "-", path])
var result = try Shell.run("/usr/bin/codesign", ["-f", "-s", "-", path])
if !result.succeeded && result.stderr.contains("resource fork, Finder information, or similar detritus not allowed") {
try removeSigningDetritus(at: url, step: step)
result = try Shell.run("/usr/bin/codesign", ["-f", "-s", "-", path])
}
guard result.succeeded else {
throw StepFailure(
step: step,
Expand All @@ -350,6 +355,29 @@ enum SteamInstaller {
}
}

// codesign rejects these two Finder attributes. Keep quarantine, provenance
// and every other attribute; do not use xattr -cr or follow external symlinks.
static func removeSigningDetritus(at url: URL, step: String = step) throws {
var targets = [url]
if let contents = FileManager.default.enumerator(at: url,
includingPropertiesForKeys: [.isSymbolicLinkKey]) {
targets += contents.compactMap { $0 as? URL }
}
for target in targets {
if (try target.resourceValues(forKeys: [.isSymbolicLinkKey])).isSymbolicLink == true { continue }
for name in ["com.apple.FinderInfo", "com.apple.ResourceFork"] {
let result = target.path.withCString { path in
name.withCString { removexattr(path, $0, XATTR_NOFOLLOW) }
}
let errorNumber = errno
if result != 0 && errorNumber != ENOATTR {
throw StepFailure(step: step, detail: "Could not remove signing metadata from \(target.path): "
+ String(cString: strerror(errorNumber)))
}
}
}
}

static func cdhash(of app: URL) -> String? {
guard let result = try? Shell.run("/usr/bin/codesign", ["-dvvv", app.path(percentEncoded: false)]) else { return nil }
return (result.stdout + result.stderr).split(separator: "\n")
Expand Down
Loading