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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
build:
runs-on: macos-15
steps:
- uses: actions/checkout@v4
- name: Build
run: swift build
- name: Test
run: swift test
- name: Bundle app
run: ./build.sh
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# SwiftPM / Xcode
.build/
.swiftpm/
xcuserdata/
DerivedData/

# Build output
Stealth Notes.app/

# macOS
.DS_Store
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 junk-debug

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ let package = Package(
name: "StealthNotes",
platforms: [.macOS(.v14)],
targets: [
.executableTarget(name: "StealthNotes", path: "Sources/StealthNotes")
.executableTarget(name: "StealthNotes", path: "Sources/StealthNotes"),
.testTarget(name: "StealthNotesTests", dependencies: ["StealthNotes"], path: "Tests/StealthNotesTests"),
]
)
110 changes: 86 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,49 +1,111 @@
# Stealth Notes
<p align="center">
<img src="docs/icon.png" width="128" alt="Stealth Notes icon">
</p>

A tiny native macOS app for presenters: float your notes above every window — including full-screen slides — while staying **invisible to screen sharing, recording and screenshots** (`NSWindow.sharingType = .none`).
<h1 align="center">Stealth Notes</h1>

![icon](docs/icon.png)
<p align="center">
Speaker notes that float above your slides — and stay off the shared screen.<br>
A small native macOS app built with SwiftUI + AppKit.
</p>

<p align="center">
<a href="https://github.com/Junk-debug/stealth-notes/actions/workflows/ci.yml"><img src="https://github.com/Junk-debug/stealth-notes/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
<img src="https://img.shields.io/badge/macOS-14%2B-blue" alt="macOS 14+">
<img src="https://img.shields.io/badge/Swift-5.9-orange" alt="Swift 5.9">
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT license"></a>
</p>

## Why

When you present over Zoom, Meet or Teams with a single screen, your notes have nowhere to go: put them in a window and the audience sees them too, put them on paper and you keep looking away. Stealth Notes is a teleprompter-style overlay for that situation — your notes float above the slides (full-screen apps included), and the windows are flagged so screen sharing leaves them out.

## Features

- **Manager window** — native SwiftUI split view: sidebar with notes, distraction-free editor, inline rename (double-click), everything auto-saved as plain `.txt` files in `~/Library/Application Support/StealthNotes/Notes/`.
- **Overlays** — borderless rounded cards that float above all windows (full-screen apps included). Open several at once; each remembers its position. Close button appears on hover only.
- **Stealth** — overlays are excluded from screen capture: Zoom/Meet/Teams screen sharing, recordings and screenshots never see them. Only programmatic capture on this Mac is affected — a phone camera pointed at the screen still sees everything.
- **Menu bar** — toggle any note's overlay, show/hide all, or close all from the eye icon.
- **Global hotkey** — show/hide all overlays from anywhere (default ⌃⌥N, configurable).
- **Native settings** — theme, font, text size, opacity, shortcut. Standard macOS Settings tabs.
- **Floating overlays** — borderless cards above every window and Space, including full-screen apps. Open several notes at once; each remembers its position and size.
- **Kept out of screen sharing** — overlays use `NSWindow.sharingType = .none`, which conferencing apps and screenshots respect. See [Limitations](#limitations) for what this does *not* cover.
- **Sections for talks** — split a note with a line containing only `---` and page through it with global hotkeys (`⌃⌥→` / `⌃⌥←`). The overlay shows a progress bar, dots and the current step's heading.
- **Click-through by default** — overlays never steal clicks from your slides. Hold a key (⌘ by default) to drag, scroll or close them; the border lights up while they are interactive. No Accessibility permission is required.
- **Markdown** — headings, lists, quotes, inline styles and code blocks render in overlays; the editor has syntax highlighting and an Edit ⇄ Preview toggle (`⌘E`).
- **Plain files** — notes are `.txt` files in `~/Library/Application Support/StealthNotes/Notes/`, saved as you type. Import `.md` / `.txt` with the button or by dropping files onto the window.
- **Native UI** — sidebar manager window, menu bar item, standard Settings window (appearance, text size, background/text opacity, fonts including bundled JetBrains Mono).

## Install

Requires **macOS 14 Sonoma or later** and Xcode (or the Xcode Command Line Tools) with Swift 5.9+.

```bash
git clone https://github.com/Junk-debug/stealth-notes.git
cd stealth-notes
./build.sh --install
```

`./build.sh` builds `Stealth Notes.app` in the repo folder; `--install` also copies it to `/Applications`.

The app is ad-hoc signed, not notarized. On first launch macOS will refuse to open it: right-click the app → **Open**, or allow it in **System Settings → Privacy & Security**.

## Usage

1. Open **Stealth Notes** and write your notes in the editor (Markdown works).
2. Separate the parts of your talk with `---` lines.
3. Click the **eye** button in the toolbar (or use the menu bar icon) to float the note.
4. Start sharing and page through sections with `⌃⌥→` / `⌃⌥←`.

| Shortcut | Action | Scope |
| --- | --- | --- |
| `⌃⌥N` *(configurable)* | Show / hide all overlays | Global |
| `⌃⌥→` / `⌃⌥←` | Next / previous section | Global |
| Hold `⌘` *(configurable)* | Make overlays interactive (or click-through, if you flip the mode) | Global |
| `⌘N` | New note | Manager window |
| `⌘E` | Toggle Edit / Preview | Manager window |

## Limitations

- `sharingType = .none` is a request to capture APIs, not a guarantee. Video-call apps generally respect it, but behavior depends on the capturing app and macOS version — some tools that use ScreenCaptureKit directly (e.g. QuickTime or OBS display capture) have been reported to record such windows on macOS 15. **Do a test share before a real talk.**
- It only affects software capture on this Mac. A camera pointed at the screen sees everything.
- Global hotkeys can clash with other apps that register the same combination; pick another one in Settings if a shortcut does nothing.

## Intended use

Stealth Notes is built for presenters: talks, demos, webinars, recorded walkthroughs — situations where speaker notes are normal and expected. Please don't use it where outside notes are not allowed, such as exams, proctored tests or interviews that ask you not to use them.

## How it works

The interesting parts, if you are reading the code:

- **Overlay windows** (`Overlay/OverlayManager.swift`) — non-activating `NSPanel`s at `.statusBar` level with `.canJoinAllSpaces` + `.fullScreenAuxiliary`, so they sit above full-screen apps without taking focus from them.
- **Hold-to-interact without Accessibility permission** — instead of a global `flagsChanged` monitor (which needs the permission), a timer polls `CGEventSource.flagsState(.combinedSessionState)` and toggles `ignoresMouseEvents` on the panels.
- **Global hotkeys** (`Support/HotKeyCenter.swift`) — Carbon `RegisterEventHotKey`, which works while other apps are frontmost and needs no permissions.
- **Markdown** (`Markdown/`) — a small line-based parser feeding SwiftUI views for overlays, and regex highlighting on an `NSTextView` for the editor.
- **No Xcode project** — it is a plain Swift package; `build.sh` assembles the `.app` bundle (binary, `Info.plist`, icon, fonts loaded via `ATSApplicationFontsPath`) and ad-hoc signs it.

## Project layout

```
Package.swift Swift Package Manager manifest
build.sh builds and (optionally) installs Stealth Notes.app
build.sh builds, signs and optionally installs Stealth Notes.app
Sources/StealthNotes/
App/ @main app, scenes, menu bar
Models/ Note + NotesStore (plain .txt files on disk)
Manager/ main window: sidebar, editor, import
Overlay/ floating panels, click-through logic, overlay view
Markdown/ parser, SwiftUI renderer, highlighting editor
Settings/ presets, keys, settings tabs
Support/ Carbon global hotkeys
Settings/ presets, option lists, settings tabs
Support/ Carbon global hotkeys, blur background
Tests/StealthNotesTests/ unit tests for the Markdown parser
Resources/ Info.plist, AppIcon.icns, bundled fonts
```

## Build
## Development

```bash
./build.sh # builds Stealth Notes.app
./build.sh --install # builds and installs into /Applications
swift build # debug build
swift test # run unit tests
./build.sh # release .app bundle
```

## Icon

The app icon is a static asset: `AppIcon.icns` (preview in `docs/icon.png`).

## Fonts

JetBrains Mono (OFL license) is bundled inside the app at `Contents/Resources/Fonts` and loaded via `ATSApplicationFontsPath` — no system installation required.
You can also open `Package.swift` in Xcode to edit and run the app with the debugger.

## Requirements
## License

macOS 14+ (SwiftUI `MenuBarExtra`, Settings scenes). No dependencies, no Xcode project — a single `main.swift`.
[MIT](LICENSE). JetBrains Mono is bundled under the [SIL Open Font License](Resources/Fonts/OFL.txt).
2 changes: 2 additions & 0 deletions Resources/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
<string>2.0</string>
<key>CFBundleVersion</key>
<string>23</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSPrincipalClass</key>
Expand Down
2 changes: 1 addition & 1 deletion Sources/StealthNotes/App/MenuBarContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ struct MenuBarContent: View {
OverlayManager.shared.toggleAllVisibility()
}
.keyboardShortcut(.none)
.badge(Text(hotKeyOptions[hotKeyIndex].title))
.badge(Text(hotKeyOption(at: hotKeyIndex).title))
Button("Close All Overlays") {
OverlayManager.shared.closeAll()
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/StealthNotes/Models/NotesStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ final class NotesStore: ObservableObject {
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
load()
if notes.isEmpty {
_ = create(named: "Welcome", text: "Your presentation notes live here.\n\nClick the eye button in the toolbar to float this note above every window — including full-screen slides. Floating notes are invisible to screen sharing, recording and screenshots.")
_ = create(named: "Welcome", text: "Your presentation notes live here.\n\nClick the eye button in the toolbar to float this note above every window — including full-screen slides. Floating notes are hidden from screen sharing and screenshots.")
}
}

Expand Down
3 changes: 1 addition & 2 deletions Sources/StealthNotes/Overlay/OverlayManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@ final class OverlayManager: NSObject, ObservableObject, NSWindowDelegate {
guard !panels.isEmpty else { return }
let defaults = UserDefaults.standard
let passThroughByDefault = defaults.object(forKey: SettingsKeys.passThrough) as? Bool ?? true
let idx = defaults.integer(forKey: SettingsKeys.holdKey)
let holdFlags = holdKeyOptions.indices.contains(idx) ? holdKeyOptions[idx].cgFlags : holdKeyOptions[0].cgFlags
let holdFlags = holdKeyOption(at: defaults.integer(forKey: SettingsKeys.holdKey)).cgFlags
let current = CGEventSource.flagsState(.combinedSessionState)
let held = current.isSuperset(of: holdFlags)
let ignores = (passThroughByDefault != held)
Expand Down
10 changes: 10 additions & 0 deletions Sources/StealthNotes/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ let holdKeyOptions: [HoldKeyOption] = [
HoldKeyOption(title: "⌃ Control", cgFlags: .maskControl),
]

/// Settings store option indices; fall back to the default when a stored index
/// no longer exists (e.g. it was saved by a build with a different option list).
func holdKeyOption(at index: Int) -> HoldKeyOption {
holdKeyOptions.indices.contains(index) ? holdKeyOptions[index] : holdKeyOptions[0]
}

struct HotKeyOption {
let title: String
let keyCode: UInt32
Expand All @@ -132,3 +138,7 @@ let hotKeyOptions: [HotKeyOption] = [
HotKeyOption(title: "⌃⌥⌘H", keyCode: UInt32(kVK_ANSI_H), modifiers: UInt32(controlKey | optionKey | cmdKey)),
HotKeyOption(title: "F13", keyCode: UInt32(kVK_F13), modifiers: 0),
]

func hotKeyOption(at index: Int) -> HotKeyOption {
hotKeyOptions.indices.contains(index) ? hotKeyOptions[index] : hotKeyOptions[0]
}
2 changes: 1 addition & 1 deletion Sources/StealthNotes/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ struct BehaviorSettings: View {
@AppStorage(SettingsKeys.passThrough) private var passThroughByDefault = true
@AppStorage(SettingsKeys.holdKey) private var holdKeyIndex = 0

private var holdTitle: String { holdKeyOptions[holdKeyIndex].title.components(separatedBy: " ").first ?? "" }
private var holdTitle: String { holdKeyOption(at: holdKeyIndex).title.components(separatedBy: " ").first ?? "" }

var body: some View {
Form {
Expand Down
2 changes: 1 addition & 1 deletion Sources/StealthNotes/Support/HotKeyCenter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ final class HotKeyCenter {
private var handlerInstalled = false

func register(optionIndex: Int) {
let option = hotKeyOptions.indices.contains(optionIndex) ? hotKeyOptions[optionIndex] : hotKeyOptions[0]
let option = hotKeyOption(at: optionIndex)
for ref in refs { if let ref { UnregisterEventHotKey(ref) } }
refs.removeAll()

Expand Down
64 changes: 64 additions & 0 deletions Tests/StealthNotesTests/MarkdownTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import XCTest
@testable import StealthNotes

final class MarkdownSectionsTests: XCTestCase {
func testSplitsOnSeparatorLines() {
let text = "Intro\n---\nMiddle\n --- \nEnd"
XCTAssertEqual(Markdown.sections(of: text), ["Intro", "Middle", "End"])
}

func testDropsEmptySections() {
XCTAssertEqual(Markdown.sections(of: "---\nOnly\n---\n\n---"), ["Only"])
}

func testTextWithoutSeparatorIsOneSection() {
XCTAssertEqual(Markdown.sections(of: "Just one"), ["Just one"])
}

func testEmptyTextStaysOneSection() {
XCTAssertEqual(Markdown.sections(of: "").count, 1)
}
}

final class MarkdownBlocksTests: XCTestCase {
private func describe(_ text: String) -> [String] {
Markdown.blocks(of: text).map { block in
switch block {
case .heading(let s, let level): return "h\(level):\(s)"
case .bullet(let s): return "bullet:\(s)"
case .numbered(let s, let n): return "\(n).:\(s)"
case .quote(let s): return "quote:\(s)"
case .code(let s): return "code:\(s)"
case .paragraph(let s): return "p:\(s)"
case .blank: return "blank"
}
}
}

func testHeadings() {
XCTAssertEqual(describe("# One\n## Two\n### Three\n#### Four"),
["h1:One", "h2:Two", "h3:Three", "p:#### Four"])
}

func testLists() {
XCTAssertEqual(describe("- dash\n* star\n12. twelfth"),
["bullet:dash", "bullet:star", "12.:twelfth"])
}

func testNumberWithoutSpaceIsParagraph() {
XCTAssertEqual(describe("3.14 is pi"), ["p:3.14 is pi"])
}

func testQuoteAndBlank() {
XCTAssertEqual(describe("> said\n\nafter"), ["quote:said", "blank", "p:after"])
}

func testFencedCodeKeepsRawLines() {
XCTAssertEqual(describe("```swift\n# not a heading\n - not a bullet\n```"),
["code:# not a heading\n - not a bullet"])
}

func testUnclosedFenceStillEmitsCode() {
XCTAssertEqual(describe("```\nlet x = 1"), ["code:let x = 1"])
}
}
4 changes: 4 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ cp Resources/Info.plist "$APP/Contents/Info.plist"
cp Resources/AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"
cp -R Resources/Fonts "$APP/Contents/Resources/Fonts"

# Ad-hoc sign the whole bundle so macOS treats it as one consistent app
# (no Apple Developer ID needed; Gatekeeper will still ask on first launch).
codesign --force --sign - "$APP"

echo "Built: $APP"
if [[ "${1:-}" == "--install" ]]; then
pkill -x StealthNotes || true
Expand Down
Loading