Skip to content
Open
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
15 changes: 9 additions & 6 deletions OpenWhisp/Services/FileOutputFormatter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import Foundation
/// note-taking / daily-log case) or overwrites the file with the latest one (a
/// scratch "last dictation" buffer). Persisted, so the raw values are a stored
/// contract — pin them.
enum FileOutputMode: String, Codable, CaseIterable, Equatable {
/// `public` so a script plugin's `writeFile` step can name it: the plugin schema
/// reuses this config rather than inventing a second one, so there is exactly one
/// file-output contract to review and one writer to trust.
public enum FileOutputMode: String, Codable, CaseIterable, Equatable, Sendable {
/// Add the entry to the end of the existing file, separated from prior content.
case append
/// Replace the whole file with just this entry.
Expand All @@ -38,19 +41,19 @@ enum FileOutputMode: String, Codable, CaseIterable, Equatable {
/// - `{{datetime}}` → `yyyy-MM-dd HH:mm`
/// e.g. `template: "## {{datetime}}"` renders `## 2026-07-09 14:30` above the text.
/// When `template` is nil or blank, the entry is just the dictation text (no heading).
struct FileOutputConfig: Codable, Equatable {
public struct FileOutputConfig: Codable, Equatable, Sendable {
/// Absolute path to the target file (e.g. an Obsidian daily note). A relative
/// path is resolved by the writer against the user's home directory; the
/// formatter itself never touches the path — it only renders content.
var path: String
public var path: String
/// Optional heading/prefix template rendered above the text. See the type doc
/// for the supported `{{date}}` / `{{time}}` / `{{datetime}}` tokens. nil/blank
/// = no heading.
var template: String?
public var template: String?
/// Append to the file or overwrite it.
var mode: FileOutputMode
public var mode: FileOutputMode

init(path: String, template: String? = nil, mode: FileOutputMode = .append) {
public init(path: String, template: String? = nil, mode: FileOutputMode = .append) {
self.path = path
self.template = template
self.mode = mode
Expand Down
65 changes: 59 additions & 6 deletions OpenWhisp/Services/PluginDiscovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,17 +33,33 @@ public enum PluginDiscovery {
self.source = source
}

/// Whether the host can actually open this plugin. External plugins are
/// listed but never runnable today, regardless of what their manifest
/// claims its entry kind is — a manifest cannot promote itself.
/// Whether the host can actually run this plugin.
///
/// Two disjoint routes, and the asymmetry is the security property:
///
/// - **Built-in** — compiled in and reviewed, so it may declare `.builtIn` and
/// get a window.
/// - **External** — a folder on disk may run ONLY as a `.script` plugin, whose
/// every action the host performs itself. It can never run as `.builtIn`,
/// whatever its manifest claims: **a manifest cannot promote itself** into
/// compiled code, and an on-disk folder declaring `.builtIn` names a Swift
/// type that would have to already exist in this binary.
///
/// So a dropped-in folder gained exactly one capability — composing host
/// actions — and gained no path at all to the in-process execution
/// docs/ROADMAP.md §6 rules out.
public var isRunnable: Bool {
source == .builtIn && manifest.entry.isRunnable
switch source {
case .builtIn: return manifest.entry.isRunnable
case .external: return manifest.entry == .script
}
}

/// Why this plugin can't run, if it can't.
public var unavailableReason: String? {
if source == .external {
return "Installed plugins can't be loaded yet — OpenWhisp currently runs only plugins that ship with the app."
guard !isRunnable else { return nil }
if source == .external, manifest.entry == .builtIn {
return "Installed plugins can't be compiled into the app — only script plugins (\"entry\": \"script\") can be installed this way."
}
return manifest.entry.unavailableReason
}
Expand Down Expand Up @@ -126,6 +142,43 @@ public enum PluginDiscovery {
])
}

/// The directory one external plugin lives in: `<root>/<id>`.
///
/// The id has already been validated as a safe path component (`PluginManifest`
/// refuses traversal-shaped ids), and this is the ONLY place that join happens, so
/// the runner cannot accidentally build the path a different way.
public static func pluginDirectory(id: String, in root: URL) -> URL {
root.appendingPathComponent(id, isDirectory: true)
}

/// Re-read ONE plugin's manifest from disk, right now.
///
/// The runner calls this at invocation time instead of using the manifest captured
/// when the pane last listed. Editing `manifest.json` and running the plugin again
/// therefore picks up the edit with no reload, no relaunch, and no rebuild — which
/// is the hot-swap promise, and the specific bug this repo has already shipped once
/// (a plugin serving a cached value long after the source of truth changed).
///
/// Returns nil when the folder is gone, the JSON is malformed, the manifest is
/// invalid, or it claims an id other than its own directory — the same rules
/// `loadExternalManifests` applies, kept in one place so a plugin cannot be run
/// under looser validation than it was listed under.
public static func reloadManifest(
id: String,
in root: URL,
fileManager: FileManager = .default
) -> PluginManifest? {
guard PluginManifest.isSafePathComponent(id) else { return nil }
let url = pluginDirectory(id: id, in: root)
.appendingPathComponent("manifest.json")
guard let data = try? Data(contentsOf: url),
let manifest = try? JSONDecoder().decode(PluginManifest.self, from: data),
manifest.isValid,
manifest.id == id
else { return nil }
return manifest
}

/// Read every `<dir>/<id>/manifest.json` under an external plugins directory.
///
/// Tolerant by design: a malformed or unreadable manifest is SKIPPED, never
Expand Down
81 changes: 76 additions & 5 deletions OpenWhisp/Services/PluginManifest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ public struct PluginManifest: Codable, Equatable, Sendable, Identifiable {
/// doing something else. See `PluginDestination`.
public let destination: PluginDestination

/// The linear pipeline a SCRIPT plugin runs over its input text — the first shipped
/// tier of docs/PLUGINS.md § "Path to hot-swappable".
///
/// Meaningful only when `entry == .script`; a built-in plugin's behavior is its
/// compiled code, not a step list. Each step is one host-executed action (call the
/// LLM, write a file, run a bundled script, insert at the cursor) and the output of
/// one is the input of the next. The plugin never runs code in this process: it
/// composes capabilities the HOST owns, which is what makes a script plugin
/// reviewable by reading its JSON.
///
/// Defaulted like every field added after v1, so a manifest predating it decodes.
/// The rules for what a step list MEANS — validity, ordering, consent, and what an
/// unrecognized step type does — live in `PluginScriptPlan`, not here.
public let steps: [PluginStep]

public init(
id: String,
name: String,
Expand All @@ -147,7 +162,8 @@ public struct PluginManifest: Codable, Equatable, Sendable, Identifiable {
voiceTriggers: [String] = [],
appAffinity: [String] = [],
clipboardAccess: Bool = false,
destination: PluginDestination = .ownWindow
destination: PluginDestination = .ownWindow,
steps: [PluginStep] = []
) {
self.id = id
self.name = name
Expand All @@ -161,6 +177,7 @@ public struct PluginManifest: Codable, Equatable, Sendable, Identifiable {
self.appAffinity = appAffinity
self.clipboardAccess = clipboardAccess
self.destination = destination
self.steps = steps
}

/// Forward-compatible decode: every field except `id`/`name`/`symbol` is optional
Expand All @@ -179,7 +196,17 @@ public struct PluginManifest: Codable, Equatable, Sendable, Identifiable {
version = try container.decodeIfPresent(String.self, forKey: .version) ?? "0.0.0"
summary = try container.decodeIfPresent(String.self, forKey: .summary) ?? ""
symbol = try container.decode(String.self, forKey: .symbol)
entry = try container.decodeIfPresent(PluginEntryKind.self, forKey: .entry) ?? .builtIn
// An UNKNOWN entry kind decodes to `.unsupported` rather than throwing.
//
// This is not hypothetical caution: before script plugins existed, a manifest
// saying `"entry": "script"` failed this decode outright — `decodeIfPresent`
// THROWS on an unrecognized enum value rather than returning nil — and the whole
// plugin vanished from the pane instead of being listed as unrunnable. That is
// exactly the migration the schema promises never to require, so the fallback
// lives here for every future entry kind too.
entry = (try? container.decodeIfPresent(PluginEntryKind.self, forKey: .entry))
.flatMap { $0 }
?? (container.contains(.entry) ? .unsupported : .builtIn)
networkHosts = try container.decodeIfPresent([String].self, forKey: .networkHosts) ?? []
keyEquivalent = try container.decodeIfPresent(String.self, forKey: .keyEquivalent)
voiceTriggers = try container.decodeIfPresent([String].self, forKey: .voiceTriggers) ?? []
Expand All @@ -193,6 +220,13 @@ public struct PluginManifest: Codable, Equatable, Sendable, Identifiable {
destination =
(try? container.decodeIfPresent(PluginDestination.self, forKey: .destination))
.flatMap { $0 } ?? .ownWindow
// A step list that fails to decode (a step with no `type`, or a `steps` value
// that isn't an array) yields NO steps rather than throwing the plugin out of
// the list. The plugin is then listed and refused by `PluginScriptPlan` with a
// reason the user can act on — the same trade every other field here makes.
// An unknown step TYPE is not a decode failure at all; see `PluginStepKind`.
steps = (try? container.decodeIfPresent([PluginStep].self, forKey: .steps))
.flatMap { $0 } ?? []
}

/// The voice triggers this manifest may actually be routed on: trimmed,
Expand Down Expand Up @@ -284,6 +318,19 @@ public struct PluginManifest: Codable, Equatable, Sendable, Identifiable {
private static let allowedIDCharacters = CharacterSet(
charactersIn: "abcdefghijklmnopqrstuvwxyz0123456789-.")

/// Whether a string is safe to use as the plugin directory's path component.
///
/// The id rule, exposed so anything that JOINS an id onto a URL can re-check it at
/// the point of use rather than trusting that validation happened earlier. Cheap,
/// and the failure it guards against — a traversal-shaped id reaching a file API —
/// is not the kind that should depend on call order.
public static func isSafePathComponent(_ id: String) -> Bool {
guard !id.isEmpty else { return false }
guard !id.unicodeScalars.contains(where: { !allowedIDCharacters.contains($0) })
else { return false }
return !id.allSatisfy { $0 == "." }
}

/// Validate a manifest's invariants. Returns `nil` when the manifest is usable.
public func validate() -> ValidationError? {
if id.isEmpty { return .emptyID }
Expand Down Expand Up @@ -467,9 +514,21 @@ public enum PluginKeyEquivalent {
public enum PluginEntryKind: String, Codable, Equatable, Sendable, CaseIterable {

/// Compiled into the app from `plugins/<id>/` and declared in `PluginRegistry`.
/// The only kind the host can actually run today.
case builtIn

/// A MANIFEST-DRIVEN plugin: a step list the host executes over the input text.
/// Runnable from the external plugins directory — this is the tier that delivers
/// install-without-rebuild (docs/PLUGINS.md § "Path to hot-swappable" §1).
///
/// No third-party code enters this process. Every step is an action the host
/// already performs for the user (`summarizeResolved`, `FileOutputTarget`,
/// `ScriptRunner`, the text inserter), so the entitlement objection that rules out
/// `dynamicLibrary` does not apply: a script plugin composes capabilities the user
/// consented to and cannot reach past them. The one step that DOES execute code —
/// `runScript` — is bounded to the plugin's own directory and carries its own
/// separate consent. See `PluginScriptPlan`.
case script

/// A dynamically-loaded bundle. NOT IMPLEMENTED — loading third-party native
/// code into a signed, entitled, mic-and-Accessibility-holding app inherits every
/// one of those entitlements, so this needs a real security story first.
Expand All @@ -480,14 +539,26 @@ public enum PluginEntryKind: String, Codable, Equatable, Sendable, CaseIterable
/// of scope today — see docs/PLUGINS.md 'Path to hot-swappable'.
case externalProcess

/// An entry kind this build does not know — a manifest written for a NEWER
/// OpenWhisp. Never written by a manifest author; produced only by decoding an
/// unrecognized value, so such a plugin is LISTED and refused with a reason rather
/// than disappearing from the pane. See `PluginManifest.init(from:)`.
case unsupported

/// Whether the host can run this kind of plugin today.
public var isRunnable: Bool { self == .builtIn }
///
/// `.script` joins `.builtIn` here, and the two are runnable for opposite reasons:
/// a built-in is trusted because it was compiled in and reviewed; a script plugin,
/// because it cannot do anything the host doesn't do on its behalf.
public var isRunnable: Bool { self == .builtIn || self == .script }

/// Why a non-runnable plugin can't run, shown in the Plugins pane.
public var unavailableReason: String? {
switch self {
case .builtIn:
case .builtIn, .script:
return nil
case .unsupported:
return "This plugin needs a newer version of OpenWhisp — it uses a kind of entry point this version doesn't support."
case .dynamicLibrary:
return "Loadable plugin bundles aren't supported — OpenWhisp only runs plugins compiled into the app."
case .externalProcess:
Expand Down
Loading
Loading