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
1 change: 0 additions & 1 deletion .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,2 +1 @@
# Default owner for everything in the repository, including /.github/ and CI.
* @marsvogel
1 change: 0 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ updates:
directory: /
schedule:
interval: weekly
# Bundle all GitHub Actions bumps into one PR to keep the noise down.
groups:
actions:
patterns:
Expand Down
19 changes: 3 additions & 16 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ on:
pull_request:
workflow_dispatch:

# Deny-all by default; each job opts into the minimum it needs.
permissions: {}

jobs:
Expand All @@ -20,13 +19,9 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
# The "Set build number" build phase derives CFBundleVersion from the
# commit count, so the full history has to be present.
fetch-depth: 0
persist-credentials: false

# Pinned to a fixed version so new SwiftLint rules can't suddenly break CI (--strict),
# and checksum-verified so a swapped release asset cannot run in CI unnoticed.
- name: SwiftLint
env:
SWIFTLINT_VERSION: 0.65.0
Expand All @@ -44,9 +39,6 @@ jobs:
-scheme QuickWeek \
CODE_SIGN_IDENTITY="-"

# Ad-hoc signing ("-") keeps the app launchable on Apple Silicon (an unsigned
# binary is killed on launch) without a paid Developer ID. The build target
# emits the app into build/Release.
- name: Build (ad-hoc signed)
run: |
xcodebuild -project QuickWeek.xcodeproj \
Expand All @@ -64,7 +56,6 @@ jobs:
echo "marketing_version=${MARKETING_VERSION}" >> "$GITHUB_OUTPUT"
echo "build_number=$(git rev-list --count HEAD)" >> "$GITHUB_OUTPUT"

# ditto (not zip) preserves the .app bundle structure and its ad-hoc signature.
- name: Package app as ZIP
run: ditto -c -k --keepParent build/Release/QuickWeek.app QuickWeek.zip

Expand All @@ -79,23 +70,20 @@ jobs:
QuickWeek.zip
QuickWeek.zip.sha256

# Separate job so write scopes are only granted where the release is created.
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: build
runs-on: ubuntu-latest
permissions:
contents: write # create the GitHub release
id-token: write # sign the build-provenance attestation
attestations: write # store the attestation
contents: write
id-token: write
attestations: write
steps:
- name: Download artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: QuickWeek

# Binds the exact ZIP to the workflow run and source commit; users can verify
# it with: gh attestation verify QuickWeek.zip -R marsvogel/QuickWeek
- name: Attest build provenance
uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
with:
Expand All @@ -107,7 +95,6 @@ jobs:
GH_REPO: ${{ github.repository }}
run: |
TAG="v${{ needs.build.outputs.marketing_version }}.${{ needs.build.outputs.build_number }}"
# Idempotent: a re-run on the same commit must not hard-fail on an existing release.
if gh release view "$TAG" >/dev/null 2>&1; then
echo "Release $TAG already exists — skipping."
exit 0
Expand Down
10 changes: 0 additions & 10 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
# CodeQL advanced setup, replacing GitHub's default setup so the Swift build is
# driven explicitly (build-mode: manual) instead of relying on the autobuilder.
name: CodeQL

on:
push:
branches: [main]
# Restricted to PRs against main: only main receives push (baseline) analyses,
# so results for other base branches could not be compared to anything.
# Markdown-only PRs have no analyzable surface and skip the expensive macOS job.
pull_request:
branches: [main]
paths-ignore:
Expand All @@ -16,8 +11,6 @@ on:
- cron: "27 6 * * 1"
workflow_dispatch:

# Superseded runs (e.g. rapid successive pushes to a PR) analyze obsolete
# commits; cancel them instead of letting them occupy macOS runner slots.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand Down Expand Up @@ -52,9 +45,6 @@ jobs:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}

# Ad-hoc signing for the same reason as in build.yml; CodeQL only needs the
# compile to succeed so the extractor can trace it. build-for-testing compiles
# the app and the test target, so test code is analyzed too.
- name: Build
if: matrix.language == 'swift'
run: |
Expand Down
7 changes: 2 additions & 5 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
# OpenSSF Scorecard — a weekly automated audit of the repository's security
# posture (branch protection, pinned dependencies, token permissions, …).
# Results surface in the Security tab and, once published, on the Scorecard badge.
name: Scorecard

on:
Expand All @@ -18,8 +15,8 @@ jobs:
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
security-events: write # upload the SARIF result to code scanning
id-token: write # publish results to the public Scorecard API
security-events: write
id-token: write
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
Expand Down
9 changes: 8 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ xcodebuild -project QuickWeek.xcodeproj -target QuickWeek -configuration Release

The app lands in `build/Release/QuickWeek.app`. Note that plain builds use the build **target** (`-target`), while tests use the shared **scheme** (`-scheme`).

`CFBundleVersion` is derived from the Git commit count by the "Set build number" build phase, which is why CI checks out the full history. That build phase is inlined into `project.pbxproj` because the build sandbox cannot read external files; `Scripts/increment-build-number.sh` is a reference copy of the same logic for manual runs:

```sh
PROJECT_DIR="$(pwd)" bash Scripts/increment-build-number.sh
```

## Testing

```sh
Expand All @@ -25,7 +31,8 @@ Please add tests for new logic where practical — the pure calendar math lives

## Ground rules

- **Everything checked into this repository is written in English**: code, comments, string literals (including user-facing UI strings), documentation, CI configuration, and commit messages.
- **Everything checked into this repository is written in English**: code, string literals (including user-facing UI strings), documentation, CI configuration, and commit messages.
- **No explanatory comments** — code should say what it does through names and structure, and doc comments (`///`) count as comments too. If something needs explaining, rename or restructure it, and put the reasoning in the commit message or the pull request. Comments a tool acts on stay: the `# vX.Y.Z` marker on SHA-pinned actions, `// swiftlint:` directives, and `// MARK:` navigation.
- Commit messages follow the `type: subject` convention (e.g. `fix: …`, `feat: …`, `docs: …`), loosely per [Conventional Commits](https://www.conventionalcommits.org). No extra tooling is required.
- Keep the app small and dependency-free — it currently builds with no third-party dependencies.
- Never commit personal data or absolute user paths.
Expand Down
119 changes: 71 additions & 48 deletions QuickWeek/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,85 +6,108 @@ class AppDelegate: NSObject, NSApplicationDelegate {
var popover: NSPopover!
var timer: Timer?

private let fallbackRefreshInterval: TimeInterval = 3600
private let popoverSize = NSSize(width: 280, height: 320)

func applicationDidFinishLaunching(_ notification: Notification) {
// Hide from dock
NSApp.setActivationPolicy(.accessory)
hideFromDock()

// Create status bar item
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
popover = makeCalendarPopover()
statusItem = makeStatusItem()
updateCalendarWeek()

timer = makeFallbackRefreshTimer()
observeDateChanges()
}

func updateCalendarWeek() {
statusItem.button?.title = WeekCalculator.menuBarTitle(for: Date())
}

@objc func handleDateChange() {
updateCalendarWeek()
popover.contentViewController = makeCalendarViewController()
}

@objc func togglePopover(_ sender: AnyObject?) {
guard let event = NSApp.currentEvent else { return }

if event.type == .rightMouseUp {
showContextMenu()
} else {
togglePopoverVisibility(sender)
}
}

@objc func quit() {
NSApplication.shared.terminate(nil)
}

private func hideFromDock() {
NSApp.setActivationPolicy(.accessory)
}

// Update every hour as fallback
timer = Timer.scheduledTimer(withTimeInterval: 3600, repeats: true) { [weak self] _ in
private func makeStatusItem() -> NSStatusItem {
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
if let button = statusItem.button {
button.action = #selector(togglePopover)
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
}
return statusItem
}

private func makeFallbackRefreshTimer() -> Timer {
Timer.scheduledTimer(withTimeInterval: fallbackRefreshInterval, repeats: true) { [weak self] _ in
self?.updateCalendarWeek()
}
}

// Update immediately when the date changes (midnight)
private func observeDateChanges() {
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDateChange),
name: .NSCalendarDayChanged,
object: nil
)

// Update when waking from sleep
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(handleDateChange),
name: NSWorkspace.didWakeNotification,
object: nil
)
}

// Create popover with calendar view
popover = NSPopover()
popover.contentSize = NSSize(width: 280, height: 320)
private func makeCalendarPopover() -> NSPopover {
let popover = NSPopover()
popover.contentSize = popoverSize
popover.behavior = .transient
popover.contentViewController = NSHostingController(rootView: CalendarView())

// Set up button action for left click
if let button = statusItem.button {
button.action = #selector(togglePopover)
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
}
popover.contentViewController = makeCalendarViewController()
return popover
}

func updateCalendarWeek() {
statusItem.button?.title = WeekCalculator.menuBarTitle(for: Date())
private func makeCalendarViewController() -> NSViewController {
NSHostingController(rootView: CalendarView())
}

@objc func handleDateChange() {
updateCalendarWeek()
// Recreate popover content so CalendarView picks up the new date
popover.contentViewController = NSHostingController(rootView: CalendarView())
private func showContextMenu() {
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "QuickWeek", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
statusItem.menu = menu
statusItem.button?.performClick(nil)
statusItem.menu = nil
}

@objc func togglePopover(_ sender: AnyObject?) {
guard let event = NSApp.currentEvent else { return }

if event.type == .rightMouseUp {
// Right click - show context menu
let menu = NSMenu()
menu.addItem(NSMenuItem(title: "QuickWeek", action: nil, keyEquivalent: ""))
menu.addItem(NSMenuItem.separator())
menu.addItem(NSMenuItem(title: "Quit", action: #selector(quit), keyEquivalent: "q"))
statusItem.menu = menu
statusItem.button?.performClick(nil)
statusItem.menu = nil
private func togglePopoverVisibility(_ sender: AnyObject?) {
if popover.isShown {
popover.performClose(sender)
} else {
// Left click - toggle popover
if popover.isShown {
popover.performClose(sender)
} else {
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
if let button = statusItem.button {
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
popover.contentViewController?.view.window?.makeKey()
}
}
}

@objc func quit() {
NSApplication.shared.terminate(nil)
}
}
Loading