4 Commits

8 changed files with 293 additions and 14 deletions

5
.gitignore vendored
View File

@@ -80,4 +80,7 @@ dist/
build/
# Mac... files
**/.DS_Store
**/.DS_Store
# Releases
releases/

View File

@@ -24,6 +24,7 @@
34AF69BF7AF8DC78ADE3774A /* GeneralSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27E7EEEDFBD8D9CEB8FD86A5 /* GeneralSettingsView.swift */; };
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 297BA3E5B837FBDDEED9DE66 /* NotchSettings.swift */; };
40183737D8C237022D0882FD /* TerminalScrollbackEstimatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */; };
4BA9D6274CFD6A87DC397B8E /* MouseAwareTerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982BA8965FE92A59D66F378B /* MouseAwareTerminalView.swift */; };
4CFA0C79095ACE0A327A2469 /* WorkspaceRegistry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E97758F68FACCFFACA895B7 /* WorkspaceRegistry.swift */; };
4D335F67B71F7DD977B6AEF9 /* AppSettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E6C98C406899F4B242075AF /* AppSettingsStore.swift */; };
4E0AD14B7427532271E485AA /* NotchWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */; };
@@ -56,6 +57,7 @@
D088BC850F37E717844761C6 /* CommandNotchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5159CB9DBE2BAA0D2E201C39 /* CommandNotchApp.swift */; };
D2468B19D6F0A2C1DFDFE2B7 /* ScreenContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A770A63582CF9834F4E7F058 /* ScreenContextTests.swift */; };
D4CEB4B895F75D91FA892A06 /* PopoutWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB593A2546BF2C0BE8E40387 /* PopoutWindowController.swift */; };
D6BBC4DE23DEA39D9713469A /* TerminalScrollWheelRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7315EA485A52A8DC42DF925E /* TerminalScrollWheelRouterTests.swift */; };
DAD3AB4A0DAADA32C02D959E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3125FD3DC55420122CF85D80 /* SettingsView.swift */; };
DCFD5B03E64A46783F46726B /* TerminalCommandArrowBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = F22AA47452CF798A977A6F47 /* TerminalCommandArrowBehavior.swift */; };
DD116B0FCC341D66F1534EC4 /* TerminalScrollbackEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 165DCCD7BB164A6470D49BBF /* TerminalScrollbackEstimator.swift */; };
@@ -110,6 +112,7 @@
726B935606FD961FD7E8C2BE /* CommandNotchUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandNotchUITests.swift; sourceTree = "<group>"; };
728B3125F7F7FDB7313D2DC6 /* SettingsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWindowController.swift; sourceTree = "<group>"; };
72A1D3D12BAC593838B3125C /* TabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBar.swift; sourceTree = "<group>"; };
7315EA485A52A8DC42DF925E /* TerminalScrollWheelRouterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalScrollWheelRouterTests.swift; sourceTree = "<group>"; };
74463E4EAB78F56345360CD5 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = "<group>"; };
7B2BCA543CE54DAB1DB80E43 /* WorkspaceSummary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceSummary.swift; sourceTree = "<group>"; };
8210D7783A614FD7190F5DDD /* LaunchAtLoginHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginHelper.swift; sourceTree = "<group>"; };
@@ -118,6 +121,7 @@
8BB1C403BC2157756F572ACF /* HotkeyBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HotkeyBinding.swift; sourceTree = "<group>"; };
8CB8AF76C0F728897A26D7EF /* ScreenManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenManager.swift; sourceTree = "<group>"; };
900F0476BE9E3600FBD371BB /* SettingsBindings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsBindings.swift; sourceTree = "<group>"; };
982BA8965FE92A59D66F378B /* MouseAwareTerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MouseAwareTerminalView.swift; sourceTree = "<group>"; };
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTermView.swift; sourceTree = "<group>"; };
9E6C98C406899F4B242075AF /* AppSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettingsStore.swift; sourceTree = "<group>"; };
A64A11F27E65B342B991629A /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
@@ -227,6 +231,7 @@
C0D19729317029008D81F361 /* TerminalCommandArrowBehaviorTests.swift */,
D77DC2AED643512C786AD70A /* TerminalScrollbackEstimatorTests.swift */,
40D9E323185F6D722B360C3C /* TerminalScrollCoordinatorTests.swift */,
7315EA485A52A8DC42DF925E /* TerminalScrollWheelRouterTests.swift */,
D288132700770C4A625A15F6 /* WindowFrameCalculatorTests.swift */,
591FCE91AF83A8A8E44E1625 /* WorkspaceRegistryTests.swift */,
4FA62DFE9AD003F4C5B55F14 /* WorkspaceStoreTests.swift */,
@@ -257,6 +262,7 @@
isa = PBXGroup;
children = (
2B81432CECBDB61D21EE4DC3 /* HotkeyRecorderView.swift */,
982BA8965FE92A59D66F378B /* MouseAwareTerminalView.swift */,
3F57837A7115DEEE11E14B40 /* NotchShape.swift */,
EFAC70814C72BAF76D90B9DF /* NotchWindow.swift */,
9C55F29B779DA0E5C5FC8627 /* SwiftTermView.swift */,
@@ -433,6 +439,7 @@
8A1B2A4CD61B0D9A4BAB075B /* ScreenRegistryTests.swift in Sources */,
CFBBE994BA6BAD4658AAB9CB /* TerminalCommandArrowBehaviorTests.swift in Sources */,
21373D6E9C2F34FD63CEC6A5 /* TerminalScrollCoordinatorTests.swift in Sources */,
D6BBC4DE23DEA39D9713469A /* TerminalScrollWheelRouterTests.swift in Sources */,
40183737D8C237022D0882FD /* TerminalScrollbackEstimatorTests.swift in Sources */,
0F133E8A88D2E313D90C32AD /* WindowFrameCalculatorTests.swift in Sources */,
154F363D434A26105C5999B5 /* WorkspaceRegistryTests.swift in Sources */,
@@ -459,6 +466,7 @@
E792411FA82E79E810F4B4C3 /* HotkeyRecorderView.swift in Sources */,
6A18F6635B509FF58669F505 /* HotkeySettingsView.swift in Sources */,
12F68EDA880030DAB644FF5F /* LaunchAtLoginHelper.swift in Sources */,
4BA9D6274CFD6A87DC397B8E /* MouseAwareTerminalView.swift in Sources */,
A74E14B8AD19C53820853D8E /* NSScreen+Extensions.swift in Sources */,
EE72479BA5A25FF31BACCC50 /* NotchOrchestrator.swift in Sources */,
3B69CB3CDEC2E5F2DCE600F9 /* NotchSettings.swift in Sources */,
@@ -511,20 +519,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = G698BP272N;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
};
name = Debug;
@@ -616,20 +618,14 @@
buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = CommandNotch/Resources/CommandNotch.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
DEVELOPMENT_TEAM = G698BP272N;
INFOPLIST_FILE = CommandNotch/Resources/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools";
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.commandnotch.app;
PRODUCT_NAME = CommandNotch;
PROVISIONING_PROFILE_SPECIFIER = "";
SDKROOT = macosx;
};
name = Release;

View File

@@ -0,0 +1,49 @@
import AppKit
import SwiftTerm
struct TerminalScrollWheelRouter {
static func shouldSendMouseWheel(
allowMouseReporting: Bool,
mouseMode: Terminal.MouseMode,
deltaY: Double
) -> Bool {
allowMouseReporting && mouseMode != .off && deltaY != 0
}
static func velocity(for deltaY: Double) -> Int {
let magnitude = Int(abs(deltaY))
if magnitude > 9 {
return 20
}
if magnitude > 5 {
return 10
}
if magnitude > 1 {
return 3
}
return 1
}
static func gridPosition(
point: CGPoint,
bounds: CGRect,
cols: Int,
rows: Int
) -> (x: Int, y: Int, pixelX: Int, pixelY: Int) {
let safeCols = max(cols, 1)
let safeRows = max(rows, 1)
let width = max(bounds.width, 1)
let height = max(bounds.height, 1)
let clampedX = min(max(point.x, 0), width)
let clampedY = min(max(point.y, 0), height)
let cellWidth = width / CGFloat(safeCols)
let cellHeight = height / CGFloat(safeRows)
let column = min(max(Int(clampedX / cellWidth), 0), safeCols - 1)
let row = min(max(Int((height - clampedY) / cellHeight), 0), safeRows - 1)
let pixelX = min(max(Int(clampedX), 0), Int(width))
let pixelY = min(max(Int(height - clampedY), 0), Int(height))
return (column, row, pixelX, pixelY)
}
}

View File

@@ -62,6 +62,7 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
let terminalView: TerminalView
private var process: LocalProcess?
private var keyEventMonitor: Any?
private var scrollEventMonitor: Any?
private let backgroundColor = NSColor.black
private let configuredShellPath: String
private var scrollbackLines: Int
@@ -88,12 +89,14 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
super.init()
terminalView.terminalDelegate = self
installOsc52ClipboardHandler()
let font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
terminalView.font = font
applyTheme(theme)
updateScrollbackLines(self.scrollbackLines)
installCommandArrowMonitor()
installScrollWheelMonitor()
if startImmediately {
startShell()
@@ -106,6 +109,9 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
if let keyEventMonitor {
NSEvent.removeMonitor(keyEventMonitor)
}
if let scrollEventMonitor {
NSEvent.removeMonitor(scrollEventMonitor)
}
}
// MARK: - Shell management
@@ -153,6 +159,23 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
return ProcessInfo.processInfo.environment["SHELL"] ?? "/bin/zsh"
}
private func installOsc52ClipboardHandler() {
let maxPayloadSize = 1_048_576 // 1 MB
terminalView.getTerminal().registerOscHandler(code: 52) { [weak self] data in
guard data.count >= 2,
data[data.startIndex] == UInt8(ascii: "c"),
data[data.startIndex + 1] == UInt8(ascii: ";") else { return }
let base64 = Data(data[(data.startIndex + 2)...])
guard let content = Data(base64Encoded: base64),
content.count <= maxPayloadSize,
let string = String(data: content, encoding: .utf8) else { return }
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(string, forType: .string)
}
}
private func installCommandArrowMonitor() {
keyEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
guard let self else { return event }
@@ -173,6 +196,53 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
}
}
private func installScrollWheelMonitor() {
scrollEventMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in
guard let self else { return event }
guard let window = self.terminalView.window else { return event }
guard event.window === window else { return event }
guard window.firstResponder === self.terminalView else { return event }
let terminal = self.terminalView.getTerminal()
guard TerminalScrollWheelRouter.shouldSendMouseWheel(
allowMouseReporting: self.terminalView.allowMouseReporting,
mouseMode: terminal.mouseMode,
deltaY: event.deltaY
) else {
return event
}
let localPoint = self.terminalView.convert(event.locationInWindow, from: nil)
let dims = terminal.getDims()
let hit = TerminalScrollWheelRouter.gridPosition(
point: localPoint,
bounds: self.terminalView.bounds,
cols: dims.cols,
rows: dims.rows
)
let button = event.deltaY > 0 ? 4 : 5
let flags = terminal.encodeButton(
button: button,
release: false,
shift: event.modifierFlags.contains(.shift),
meta: event.modifierFlags.contains(.option),
control: event.modifierFlags.contains(.control)
)
for _ in 0..<TerminalScrollWheelRouter.velocity(for: event.deltaY) {
terminal.sendEvent(
buttonFlags: flags,
x: hit.x,
y: hit.y,
pixelX: hit.pixelX,
pixelY: hit.pixelY
)
}
return nil
}
}
func updateFontSize(_ size: CGFloat) {
terminalView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
}
@@ -200,7 +270,29 @@ class TerminalSession: NSObject, ObservableObject, LocalProcessDelegate, @precon
// MARK: - LocalProcessDelegate
nonisolated func processTerminated(_ source: LocalProcess, exitCode: Int32?) {
Task { @MainActor in self.isRunning = false }
Task { @MainActor in
self.isRunning = false
self.resetTerminalModes()
}
}
private func resetTerminalModes() {
let resetSequences: [[UInt8]] = [
Array("\u{1b}[?9l".utf8),
Array("\u{1b}[?1000l".utf8),
Array("\u{1b}[?1002l".utf8),
Array("\u{1b}[?1003l".utf8),
Array("\u{1b}[?1006l".utf8),
Array("\u{1b}[?1015l".utf8),
Array("\u{1b}[?2004l".utf8),
Array("\u{1b}[?1l".utf8),
Array("\u{1b}[?1049l".utf8),
Array("\u{1b}[?25h".utf8),
]
for seq in resetSequences {
terminalView.feed(byteArray: seq[...])
}
}
nonisolated func dataReceived(slice: ArraySlice<UInt8>) {

View File

@@ -0,0 +1,49 @@
import XCTest
@testable import CommandNotch
import SwiftTerm
final class TerminalScrollWheelRouterTests: XCTestCase {
func testMouseWheelForwardingRequiresMouseReportingAndActiveMouseMode() {
XCTAssertFalse(TerminalScrollWheelRouter.shouldSendMouseWheel(
allowMouseReporting: false,
mouseMode: .vt200,
deltaY: 1
))
XCTAssertFalse(TerminalScrollWheelRouter.shouldSendMouseWheel(
allowMouseReporting: true,
mouseMode: .off,
deltaY: 1
))
XCTAssertFalse(TerminalScrollWheelRouter.shouldSendMouseWheel(
allowMouseReporting: true,
mouseMode: .vt200,
deltaY: 0
))
XCTAssertTrue(TerminalScrollWheelRouter.shouldSendMouseWheel(
allowMouseReporting: true,
mouseMode: .vt200,
deltaY: -1
))
}
func testVelocityMatchesExpectedThresholds() {
XCTAssertEqual(TerminalScrollWheelRouter.velocity(for: 1), 1)
XCTAssertEqual(TerminalScrollWheelRouter.velocity(for: 2), 3)
XCTAssertEqual(TerminalScrollWheelRouter.velocity(for: 6), 10)
XCTAssertEqual(TerminalScrollWheelRouter.velocity(for: 10), 20)
}
func testGridPositionClampsToTerminalBounds() {
let hit = TerminalScrollWheelRouter.gridPosition(
point: CGPoint(x: 210, y: -10),
bounds: CGRect(x: 0, y: 0, width: 200, height: 100),
cols: 10,
rows: 5
)
XCTAssertEqual(hit.x, 9)
XCTAssertEqual(hit.y, 4)
XCTAssertEqual(hit.pixelX, 200)
XCTAssertEqual(hit.pixelY, 100)
}
}

View File

@@ -63,6 +63,7 @@ Click the preview above to watch the demo recording.
- macOS 14 or later
- Xcode 16 or later
- Homebrew `xcodegen`
- Homebrew `create-dmg` for release `.dmg` packaging
### Build
@@ -81,6 +82,33 @@ DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer \
xcodebuild build -project CommandNotch.xcodeproj -scheme CommandNotch -destination 'platform=macOS'
```
### Build a release `.dmg`
Use `create-dmg` to build the styled Finder installer window with the usual drag-to-`Applications` layout.
Install the packaging dependency once:
```bash
brew install create-dmg
```
Then build from the `app/` directory:
```bash
./scripts/build-release-dmg.sh
```
That produces:
- `releases/CommandNotch YYYY-MM-DD HH-MM-SS/CommandNotch.app`
- `releases/CommandNotch YYYY-MM-DD HH-MM-SS/CommandNotch.dmg`
Notes:
- The script regenerates the Xcode project, archives the Release build, then packages the archived app into a styled `.dmg`.
- The archive is written to `/tmp` and is only used as the source for the exported `.app`.
- If you want a distributable build signed with a specific identity, make sure your Xcode signing settings are configured before running the archive step.
## Project Layout
```text

62
scripts/build-release-dmg.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
PROJECT_DIR="$APP_ROOT/CommandNotch"
if ! command -v xcodegen >/dev/null 2>&1; then
echo "error: xcodegen is required. Install it with: brew install xcodegen" >&2
exit 1
fi
if ! command -v create-dmg >/dev/null 2>&1; then
echo "error: create-dmg is required. Install it with: brew install create-dmg" >&2
exit 1
fi
timestamp="$(date '+%Y-%m-%d %H-%M-%S')"
release_dir="$APP_ROOT/releases/CommandNotch $timestamp"
archive_path="/tmp/CommandNotch-$timestamp.xcarchive"
staging_dir="$(mktemp -d)"
app_path="$release_dir/CommandNotch.app"
dmg_path="$release_dir/CommandNotch.dmg"
cleanup() {
rm -rf "$staging_dir"
}
trap cleanup EXIT
mkdir -p "$release_dir"
cd "$PROJECT_DIR"
xcodegen generate --spec project.yml
DEVELOPER_DIR="${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer}" \
xcodebuild archive \
-project CommandNotch.xcodeproj \
-scheme CommandNotch \
-configuration Release \
-destination 'generic/platform=macOS' \
-archivePath "$archive_path"
ditto "$archive_path/Products/Applications/CommandNotch.app" "$app_path"
ditto "$app_path" "$staging_dir/CommandNotch.app"
create-dmg \
--volname "CommandNotch" \
--window-pos 200 120 \
--window-size 720 420 \
--icon-size 128 \
--icon "CommandNotch.app" 180 210 \
--icon "Applications" 540 210 \
--hide-extension "CommandNotch.app" \
--app-drop-link 540 210 \
"$dmg_path" \
"$staging_dir"
echo "Created:"
echo " $app_path"
echo " $dmg_path"