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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<a href="https://github.com/niharnm/Semper/graphs/contributors"><img src="https://img.shields.io/github/contributors/niharnm/Semper" alt="Contributors"/></a>
</p>

Control windows, files, displays, power, and sound from one native macOS menu bar app. Add the utilities you need, use them independently, or combine settings with Scenes and Presentation. Away provides an authenticated privacy curtain.
Semper is built exclusively for macOS. Control windows, files, displays, power, and sound from one native menu bar app. Add the utilities you need, use them independently, or combine settings with Scenes and Presentation. Away provides an authenticated privacy curtain.

The current download, v1.0.0, contains Sound. Source builds include additional utilities in development toward the next release. The [product status guide](guide/product-status.md) distinguishes released, integrated, and staged features.

Expand Down Expand Up @@ -58,8 +58,8 @@ not download a Semper DMG from an unofficial source.

## Architecture Highlights

- **Independent Utilities**: Home provides module summaries, attention items, up to four pinned actions, search, and recent action outcomes for the current session. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window.
- **Manual Window Layout**: Five optional actions arrange eligible windows into halves, maximize, center, or restore the preceding placement. Full-height windows and targets are refused, which can limit halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout).
- **Independent Utilities**: Home puts up to four pinned actions and utility summaries first. Keyboard search runs actions with Up/Down and Return; the full catalog and session history expand on demand. Modules can be searched and filtered. Add, pause, or remove modules individually; adding a module starts no service and requests no permission. Detailed controls open in a native window.
- **Manual Window Layout**: Eleven actions arrange eligible windows into horizontal or vertical halves, four quarters, maximize, center, or restore the preceding placement. Each has an optional shortcut. Full-height windows and targets are refused, which can limit left/right halves and maximize when both the Dock and menu bar auto-hide. Source integration and native acceptance are tracked in the [product status guide](guide/product-status.md#window-layout).
- **Local Image Copies**: File Shelf's Resize a Copy action saves one local JPEG or PNG at up to 1,024 or 2,048 pixels on its longest edge without enlargement or overwriting a file. It removes descriptive metadata and requires a destination that supports macOS file cloning. See the [image-copy guide](guide/shelf-image-copy.md) for format, size, recovery, and native acceptance limits.
- **Local Awake Sessions**: Public IOKit power assertions prevent idle system sleep, optionally keep the display on, and keep timed user sessions separate from Scene requests.
- **Authenticated Away Curtain**: One opaque panel covers each display, ordinary input is filtered, and local widgets can show time, battery, Away duration, and awake-request state.
Expand Down
14 changes: 9 additions & 5 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ The implementation from [PR #106](https://github.com/niharnm/Semper/pull/106)
is integrated on `main` through [PR #110](https://github.com/niharnm/Semper/pull/110).
Native acceptance remains open. See the [Window Layout guide](guide/window-layout.md).

- Verify all five manual commands, optional shortcuts, Home/search/pinned
- Verify all eleven source commands, including the six new half and quarter placements, optional shortcuts, Home/search/pinned
actions, intended-window selection, and later manual changes on real apps.
- Keep the conservative full-height exclusion explicit. Ordinary full-height
windows and targets are refused; halves and maximize can be unavailable when
windows and targets are refused; left/right halves and maximize can be unavailable when
both Dock and menu bar auto-hide. Smaller-window center and restore still
require eligible geometry.
- After an attempted write, an excluded or unreadable result requires manual
Expand Down Expand Up @@ -80,6 +80,9 @@ Native acceptance remains open. See the [Window Layout guide](guide/window-layou
for input limits and remaining checks. No batch processing or uploads.
- Awake and Away: keep power assertions and the curtain testable and honest
about what they do not block.
- Proposed next additions from the workflow comparison: native Share for one
File Shelf item, and extending an active Awake session without losing its
stop conditions. These are proposals, not implemented features.
- A new utility needs a clear local user job, no account requirement, the
shared lifecycle and disclosure rules, and reuse of existing services where
reasonable.
Expand Down Expand Up @@ -108,7 +111,7 @@ Focused specialist tools set the expectations each Semper module must meet:
- [FineTune](https://github.com/ronitsingh10/FineTune) for per-app audio with
AutoEQ and ISO 226 loudness compensation

Semper has not benchmarked against these tools and claims no superiority. The
The macOS experience work compares their documented workflows and selected open-source structures. No controlled performance comparison or superiority claim has been established. The
case for Semper is one shell with shared lifecycle, disclosure, and recovery
rules, and modules that can work together.

Expand All @@ -128,7 +131,7 @@ discussion and include a hardware test plan.

## Current boundaries

- Supported platform: macOS 15.4 or later.
- Platform commitment: macOS only, requiring macOS 15.4 or later. Product and interface work targets native Mac workflows; ports to other operating systems are outside the roadmap.
- Downloadable today: v1.0.0, published 2026-08-26, with Sound. Source builds,
unit tests, the static website, and signed releases through GitHub and
Homebrew are current.
Expand All @@ -140,4 +143,5 @@ discussion and include a hardware test plan.
behavior.
- Release-dependent: automatic updates require a current signed feed, and broad
compatibility claims require verified hardware reports.
- Out of scope today: Windows, Linux, iOS, cloud accounts, and audio recording.
- Outside the product scope: Windows, Linux, and iOS versions.
- Out of scope today: cloud accounts and audio recording.
188 changes: 155 additions & 33 deletions Semper/Modules/ModuleLibraryView.swift
Original file line number Diff line number Diff line change
@@ -1,60 +1,178 @@
import SwiftUI

enum ModuleLibraryFilter: String, CaseIterable, Identifiable {
case all = "All"
case added = "Added"
case available = "Available"

var id: String { rawValue }

@MainActor
func modules(in registry: ModuleRegistry, matching query: String = "") -> [UtilityModuleDescriptor] {
let terms = query.split(whereSeparator: \.isWhitespace)
return registry.modules.filter { module in
let presence = registry.state(for: module.id)?.presence
let included =
switch self {
case .all: true
case .added: presence == .added
case .available: presence == .available
}
let searchableText = "\(module.title) \(module.summary)"
return included && terms.allSatisfy { searchableText.localizedStandardContains(String($0)) }
}
}
}

struct ModuleLibraryView: View {
let registry: ModuleRegistry
let lifecycle: UtilityLifecycle
let pause: (UtilityModuleID) async throws -> Void
let remove: (UtilityModuleID) async throws -> Void
var mutationDisabledReason: String?
var open: ((UtilityModuleID) -> Void)? = nil
@State private var message: String?
@State private var searchText = ""
@State private var filter = ModuleLibraryFilter.all

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
VStack(alignment: .leading, spacing: 8) {
Text("Modules").font(.title2.bold())
Text("Modules").font(.largeTitle.weight(.semibold))
.accessibilityAddTraits(.isHeader)
Text("Choose the controls you use on your Mac.").foregroundStyle(.secondary)
}
if let mutationDisabledReason {
Label(mutationDisabledReason, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
}
if let message {
Label(message, systemImage: "exclamationmark.triangle")
.foregroundStyle(.orange)
.accessibilityAddTraits(.updatesFrequently)
}
VStack(alignment: .leading, spacing: 10) {
TextField("Search modules", text: $searchText)
.textFieldStyle(.roundedBorder)
.controlSize(.large)
.accessibilityLabel("Search modules")
.onExitCommand { searchText = "" }
Picker("Show modules", selection: $filter) {
ForEach(ModuleLibraryFilter.allCases) { option in
Text("\(option.rawValue) (\(option.modules(in: registry, matching: searchText).count))")
.tag(option)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.accessibilityLabel("Show modules")
Text(
"Add the controls you use. Adding a module starts no background work and requests no permission. Removing a module keeps its saved data."
"Adding a module starts no background work and requests no permission. Removing it keeps its saved data."
)
.font(.caption)
.foregroundStyle(.secondary)
}
if let mutationDisabledReason {
Text(mutationDisabledReason).foregroundStyle(.orange)
}
VStack(alignment: .leading, spacing: 0) {
ForEach(registry.modules) { module in
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: module.symbolName).font(.title3).frame(width: 28)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(module.title).font(.headline)
Text(module.summary).foregroundStyle(.secondary)
Text(statusText(for: module.id))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
controls(for: module.id)
.controlSize(.small)
}
if let reason = attentionReason(for: module.id) {
Text(reason).font(.callout).foregroundStyle(.orange)
}
DisclosureGroup("Permissions, activity and data") {
moduleDetails(for: module)
.padding(.top, 10)
}
.font(.subheadline)
let modules = filter.modules(in: registry, matching: searchText)
if modules.isEmpty {
emptyState
} else {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 260), spacing: 12, alignment: .top)], spacing: 12) {
ForEach(modules) { module in
moduleCard(module)
}
.padding(.vertical, 14)
if module.id != registry.modules.last?.id { Divider() }
}
}
if let message { Text(message).foregroundStyle(.orange).accessibilityAddTraits(.updatesFrequently) }
}
.padding(24)
}
.background(Color(nsColor: .windowBackgroundColor))
}

private var emptyState: some View {
ContentUnavailableView {
Label(
searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? (filter == .added ? "No modules added" : "No modules available")
: "No matching modules",
systemImage: "square.grid.2x2"
)
} description: {
if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Text("Try a tool name or what it does, such as audio, windows, or files.")
} else if filter == .added {
Text("Browse the library and add the tools you want to use.")
} else if filter == .available {
Text("There are no more modules to add on this Mac. Choose All to view the library.")
} else {
Text("Modules supported on this Mac will appear here.")
}
} actions: {
if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Button("Clear Search") { searchText = "" }
}
if filter != .all {
Button("Show All Modules") { filter = .all }
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 24)
}

private func moduleCard(_ module: UtilityModuleDescriptor) -> some View {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: module.symbolName)
.font(.title2)
.foregroundStyle(.tint)
.frame(width: 40, height: 40)
.background(Color.accentColor.opacity(0.08), in: RoundedRectangle(cornerRadius: 10))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(module.title).font(.headline)
.accessibilityAddTraits(.isHeader)
Text(statusText(for: module.id))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
Text(module.summary)
.font(.callout)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, minHeight: 34, alignment: .topLeading)
.fixedSize(horizontal: false, vertical: true)
if let reason = attentionReason(for: module.id) {
Label(reason, systemImage: "exclamationmark.triangle")
.font(.callout).foregroundStyle(.orange)
}
HStack(spacing: 8) {
if registry.state(for: module.id)?.presence == .added, let open {
Button("Open") { open(module.id) }
.buttonStyle(.borderedProminent)
.accessibilityLabel("Open \(module.title)")
.disabled(lifecycle.isShuttingDown)
}
Spacer(minLength: 0)
controls(for: module.id)
}
.controlSize(.small)
Divider()
DisclosureGroup("Permissions, activity and data") {
moduleDetails(for: module)
.font(.callout)
.padding(.top, 10)
}
.font(.caption)
.accessibilityLabel("Permissions, activity and data for \(module.title)")
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
.background(Color(nsColor: .controlBackgroundColor), in: RoundedRectangle(cornerRadius: 12))
.overlay {
RoundedRectangle(cornerRadius: 12)
.strokeBorder(Color(nsColor: .separatorColor).opacity(0.5), lineWidth: 1)
}
}

private func moduleDetails(for module: UtilityModuleDescriptor) -> some View {
Expand Down Expand Up @@ -182,12 +300,16 @@ struct ModuleLibraryView: View {
ProgressView().controlSize(.small).accessibilityLabel("Stopping module")
} else if case .failed = state.runtime, registry.pausedModuleIDs.contains(id) {
Button("Retry stop") { Task { await changeAsync { try await pause(id) } } }
.accessibilityLabel("Retry stopping \(registry.descriptor(for: id)?.title ?? id.rawValue)")
} else if registry.pausedModuleIDs.contains(id) {
Button("Resume") { change { try registry.resume(id) } }
.accessibilityLabel("Resume \(registry.descriptor(for: id)?.title ?? id.rawValue)")
} else {
Button("Pause") { Task { await changeAsync { try await pause(id) } } }
.accessibilityLabel("Pause \(registry.descriptor(for: id)?.title ?? id.rawValue)")
}
Button("Remove") { Task { await changeAsync { try await remove(id) } } }
.accessibilityLabel("Remove \(registry.descriptor(for: id)?.title ?? id.rawValue)")
}
.disabled(mutationDisabledReason != nil || lifecycle.stopping.contains(id) || lifecycle.isShuttingDown)
.fixedSize(horizontal: true, vertical: false)
Expand Down
Loading
Loading