From f21087f2336a3885b1d2048b99a3a729bdbdbf76 Mon Sep 17 00:00:00 2001 From: junk-debug Date: Thu, 15 Oct 2026 16:06:57 +0200 Subject: [PATCH] Repo cleanup: tests, CI, license, README rewrite, safe option lookup - Fix crash when a stored hotkey/hold-key index is out of range (holdKeyOption(at:) / hotKeyOption(at:) fall back to the default) - Add unit tests for the Markdown parser and section splitting - Add GitHub Actions CI: build, test, bundle the app - build.sh: ad-hoc sign the app bundle - Info.plist: set LSMinimumSystemVersion 14.0 - .gitignore: ignore .swiftpm, xcuserdata, DerivedData - Add MIT LICENSE - Rewrite README: install, usage, shortcuts, limitations, intended use, how it works; drop outdated single-file note - Soften the screen-capture claim in the welcome note --- .github/workflows/ci.yml | 18 +++ .gitignore | 8 ++ LICENSE | 21 ++++ Package.swift | 3 +- README.md | 110 ++++++++++++++---- Resources/Info.plist | 2 + Sources/StealthNotes/App/MenuBarContent.swift | 2 +- Sources/StealthNotes/Models/NotesStore.swift | 2 +- .../StealthNotes/Overlay/OverlayManager.swift | 3 +- .../StealthNotes/Settings/AppSettings.swift | 10 ++ .../StealthNotes/Settings/SettingsView.swift | 2 +- .../StealthNotes/Support/HotKeyCenter.swift | 2 +- Tests/StealthNotesTests/MarkdownTests.swift | 64 ++++++++++ build.sh | 4 + 14 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 Tests/StealthNotesTests/MarkdownTests.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0204dfe --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index 33dbdf0..2a3183c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,11 @@ +# SwiftPM / Xcode .build/ +.swiftpm/ +xcuserdata/ +DerivedData/ + +# Build output Stealth Notes.app/ + +# macOS .DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..92072f2 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Package.swift b/Package.swift index f91eecb..f2b9d97 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] ) diff --git a/README.md b/README.md index 2703125..a1e82c5 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,111 @@ -# Stealth Notes +

+ Stealth Notes icon +

-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`). +

Stealth Notes

-![icon](docs/icon.png) +

+ Speaker notes that float above your slides — and stay off the shared screen.
+ A small native macOS app built with SwiftUI + AppKit. +

+ +

+ CI + macOS 14+ + Swift 5.9 + MIT license +

+ +## 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). diff --git a/Resources/Info.plist b/Resources/Info.plist index 857ec98..e42c3eb 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -20,6 +20,8 @@ 2.0 CFBundleVersion 23 + LSMinimumSystemVersion + 14.0 NSHighResolutionCapable NSPrincipalClass diff --git a/Sources/StealthNotes/App/MenuBarContent.swift b/Sources/StealthNotes/App/MenuBarContent.swift index c1105f0..59b9f83 100644 --- a/Sources/StealthNotes/App/MenuBarContent.swift +++ b/Sources/StealthNotes/App/MenuBarContent.swift @@ -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() } diff --git a/Sources/StealthNotes/Models/NotesStore.swift b/Sources/StealthNotes/Models/NotesStore.swift index 531d77e..ee1ed24 100644 --- a/Sources/StealthNotes/Models/NotesStore.swift +++ b/Sources/StealthNotes/Models/NotesStore.swift @@ -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.") } } diff --git a/Sources/StealthNotes/Overlay/OverlayManager.swift b/Sources/StealthNotes/Overlay/OverlayManager.swift index 21dc22a..b158b2a 100644 --- a/Sources/StealthNotes/Overlay/OverlayManager.swift +++ b/Sources/StealthNotes/Overlay/OverlayManager.swift @@ -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) diff --git a/Sources/StealthNotes/Settings/AppSettings.swift b/Sources/StealthNotes/Settings/AppSettings.swift index eb1feaf..b742037 100644 --- a/Sources/StealthNotes/Settings/AppSettings.swift +++ b/Sources/StealthNotes/Settings/AppSettings.swift @@ -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 @@ -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] +} diff --git a/Sources/StealthNotes/Settings/SettingsView.swift b/Sources/StealthNotes/Settings/SettingsView.swift index dfdcba0..8c7edc0 100644 --- a/Sources/StealthNotes/Settings/SettingsView.swift +++ b/Sources/StealthNotes/Settings/SettingsView.swift @@ -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 { diff --git a/Sources/StealthNotes/Support/HotKeyCenter.swift b/Sources/StealthNotes/Support/HotKeyCenter.swift index 83d132f..d7ab417 100644 --- a/Sources/StealthNotes/Support/HotKeyCenter.swift +++ b/Sources/StealthNotes/Support/HotKeyCenter.swift @@ -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() diff --git a/Tests/StealthNotesTests/MarkdownTests.swift b/Tests/StealthNotesTests/MarkdownTests.swift new file mode 100644 index 0000000..0436d19 --- /dev/null +++ b/Tests/StealthNotesTests/MarkdownTests.swift @@ -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"]) + } +} diff --git a/build.sh b/build.sh index 92d4f4d..e3f7096 100755 --- a/build.sh +++ b/build.sh @@ -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