accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -11,7 +11,7 @@ A macOS menubar application for syncing and uploading files to a Cairnquire serv
- **Auto Sync**: Watch a local folder and automatically sync markdown files to the server
- **Drag & Drop Upload**: Drop files onto the upload zone to upload and copy the URL to clipboard
- **Quick Upload**: Upload files via the menubar menu or popover
- **Clipboard Notes**: Create new notes from clipboard content
- **New Notes**: Create notes from clipboard content or a terminal `$EDITOR` buffer
- **Upload Index**: View all uploaded files with timestamps
- **Custom Icons**: Choose from 11 different SF Symbols for the menubar icon
- **CLI Tool**: Command-line interface for one-off uploads and note creation
@@ -32,6 +32,19 @@ swift build
swift run CairnquireSync
```
For local development with Keychain access, sign the SwiftPM executable with a
stable Apple Development identity and run the signed binary directly:
```bash
cd apps/macos-sync
scripts/sign-dev.sh CairnquireSync
.build/debug/CairnquireSync
```
The script uses the first valid `Apple Development` code-signing identity. To
choose a specific identity, set `CAIRNQUIRE_CODESIGN_IDENTITY` to the certificate
name or SHA-1 hash shown by `security find-identity -v -p codesigning`.
Or open in Xcode:
```bash
open Package.swift
@@ -54,10 +67,10 @@ You can run the built executable directly after building:
## Setup
1. Start your Cairnquire server
2. Create an API token from the web UI at `/account`
2. Create an API token from the web UI at `/account/tokens/new`
3. Open the app settings (click the menubar icon → Settings)
4. Enter your server URL and API token
5. Optionally configure a sync folder and default upload folder
5. Optionally configure a sync folder, default upload folder, and New Note behavior
## Usage
@@ -75,7 +88,7 @@ You can run the built executable directly after building:
- **Drop on Icon**: Drag files directly onto the menubar icon — it bounces, then uploads
- **Sync Now**: Manually trigger a sync
- **Upload File**: Select files to upload
- **Create Note from Clipboard**: Create a new markdown note from clipboard text
- **New Note**: Create a markdown note from clipboard text or open Terminal.app with `$EDITOR`, depending on Settings
- **Drop Zone**: Open a drag-and-drop upload window
- **View Uploads**: See all uploaded files with timestamps
- **Change Icon**: Pick from 11 SF Symbols in Settings (default: `tray.circle.fill`)
@@ -90,7 +103,8 @@ swift run cairnquire-cli config \
--server http://localhost:9000 \
--token dev \
--folder "$HOME/Documents/Cairnquire" \
--upload-folder Inbox
--upload-folder Inbox \
--new-note editor
```
Run one bidirectional sync:
@@ -127,6 +141,12 @@ swift run cairnquire-cli help sync
Readable uploads (`.md`, `.markdown`, `.txt`, `.html`, and `.htm`) are published as rendered documents. If the same inbox filename already exists, the app asks whether to keep both files, replace the existing document, or cancel. If identical attachment bytes were already uploaded, the app asks whether to copy the existing URL or cancel.
When New Note is set to `Open Terminal $EDITOR`, the menubar action opens a
temporary markdown buffer in Terminal.app. The app uploads the saved buffer
after the editor exits. It uses `$EDITOR`, then `$VISUAL`, and falls back to
`vi`. Configure GUI editor launchers to wait for the buffer to close, such as
`EDITOR="code --wait"`.
## Configuration
Configuration is stored in:

View File

@@ -1,11 +1,17 @@
import Foundation
import Security
public enum NewNoteAction: String, Codable, CaseIterable {
case clipboard
case editor
}
public struct SyncConfig: Codable, Equatable {
public var serverURL: String
public var apiToken: String?
public var syncFolder: String?
public var defaultUploadFolder: String?
public var newNoteAction: NewNoteAction?
public var autoSync: Bool
public var syncInterval: Int // seconds
public var deviceId: String
@@ -30,6 +36,7 @@ public struct SyncConfig: Codable, Equatable {
apiToken: String? = nil,
syncFolder: String? = nil,
defaultUploadFolder: String? = nil,
newNoteAction: NewNoteAction? = nil,
autoSync: Bool = true,
syncInterval: Int = 30,
deviceId: String? = nil,
@@ -39,6 +46,7 @@ public struct SyncConfig: Codable, Equatable {
self.apiToken = apiToken
self.syncFolder = syncFolder
self.defaultUploadFolder = defaultUploadFolder
self.newNoteAction = newNoteAction
self.autoSync = autoSync
self.syncInterval = syncInterval
self.deviceId = deviceId ?? SyncConfig.generateDeviceId()
@@ -57,6 +65,10 @@ public struct SyncConfig: Codable, Equatable {
public var serverBaseURL: URL? {
URL(string: serverURL)
}
public var effectiveNewNoteAction: NewNoteAction {
newNoteAction ?? .clipboard
}
}
public actor ConfigStore {

View File

@@ -276,6 +276,25 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
}
}
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await uploadManager.uploadData(
data,
filename: filename,
duplicateAction: duplicateAction
)
} catch let error as APIError where error.isUploadConflict {
guard let action = promptForUploadDuplicate(error, filename: filename) else {
throw UploadCancelledError()
}
duplicateAction = action
}
}
}
public func refreshRecentUploads() async {
guard !isLoadingRecentUploads else { return }
@@ -290,6 +309,15 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
}
}
public func createNote() {
switch config.effectiveNewNoteAction {
case .clipboard:
createNoteFromClipboard()
case .editor:
createNoteInEditor()
}
}
public func createNoteFromClipboard() {
guard let text = NSPasteboard.general.string(forType: .string) else {
syncStatus = "Clipboard is empty"
@@ -305,6 +333,97 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
}
}
}
private func createNoteInEditor() {
let draftURL = FileManager.default.temporaryDirectory
.appendingPathComponent("cairnquire-\(UUID().uuidString)")
.appendingPathExtension("md")
let completionURL = draftURL.appendingPathExtension("done")
do {
try Data().write(to: draftURL, options: .atomic)
try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL)
syncStatus = "Waiting for $EDITOR to exit"
} catch {
try? FileManager.default.removeItem(at: draftURL)
syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)"
return
}
Task { [weak self] in
await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL)
}
}
private func uploadEditorDraft(draftURL: URL, completionURL: URL) async {
defer {
try? FileManager.default.removeItem(at: draftURL)
try? FileManager.default.removeItem(at: completionURL)
}
while !FileManager.default.fileExists(atPath: completionURL.path) {
try? await Task.sleep(for: .milliseconds(500))
}
do {
let data = try Data(contentsOf: draftURL)
guard !data.isEmpty else {
syncStatus = "Editor note was empty; nothing uploaded"
return
}
let filename = Self.editorNoteFilename()
let result = try await uploadDataResolvingDuplicates(data, filename: filename)
syncStatus = "Uploaded \(result.filename)"
clipboardNotice = ClipboardNotice(filename: result.filename)
await refreshRecentUploads()
} catch {
syncStatus = "Failed to upload editor note: \(error.localizedDescription)"
}
}
private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws {
let draftPath = Self.shellQuote(draftURL.path)
let completionPath = Self.shellQuote(completionURL.path)
let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\""
let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\""
let process = Process()
let errorPipe = Pipe()
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script]
process.standardError = errorPipe
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else {
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
let message = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines)
throw NSError(
domain: "CairnquireSync",
code: Int(process.terminationStatus),
userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"]
)
}
}
private static func shellQuote(_ value: String) -> String {
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
private static func appleScriptEscape(_ value: String) -> String {
value
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
}
private static func editorNoteFilename() -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
return "Note \(formatter.string(from: Date())).md"
}
}
extension MenuBarController: FolderWatcherDelegate {

View File

@@ -8,6 +8,7 @@ public struct SettingsView: View {
@State private var apiToken: String = ""
@State private var syncFolder: String = ""
@State private var defaultUploadFolder: String = ""
@State private var newNoteAction: NewNoteAction = .clipboard
@State private var autoSync: Bool = true
@State private var syncInterval: Double = 30
@State private var selectedIcon: String = "tray.circle.fill"
@@ -20,28 +21,34 @@ public struct SettingsView: View {
}
public var body: some View {
ScrollView {
VStack(spacing: 0) {
serverSection
Divider().padding(.vertical, 16)
syncSection
Divider().padding(.vertical, 16)
uploadsSection
Divider().padding(.vertical, 16)
appearanceSection
Spacer(minLength: 32)
saveButton
VStack(spacing: 0) {
ScrollView {
VStack(spacing: 0) {
serverSection
Divider().padding(.vertical, 12)
syncSection
Divider().padding(.vertical, 12)
uploadsSection
Divider().padding(.vertical, 12)
appearanceSection
}
.padding(20)
}
.padding(24)
.scrollIndicators(.hidden)
Divider()
saveButton
.padding(.horizontal, 20)
.padding(.vertical, 12)
}
.frame(width: 520, height: 600)
.frame(width: 520, height: 640)
.onAppear {
serverURL = config.serverURL
apiToken = config.apiToken ?? ""
syncFolder = config.syncFolder ?? ""
defaultUploadFolder = config.defaultUploadFolder ?? ""
newNoteAction = config.effectiveNewNoteAction
autoSync = config.autoSync
syncInterval = Double(config.syncInterval)
selectedIcon = config.iconName
@@ -69,7 +76,7 @@ public struct SettingsView: View {
HStack {
Spacer()
.frame(width: labelWidth)
Text("Create an API token from the web app at /account")
Text("Create an API token from the web app at /account/tokens/new")
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(nil)
@@ -124,11 +131,20 @@ public struct SettingsView: View {
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
formRow(label: "New Note") {
Picker("", selection: $newNoteAction) {
Text("From Clipboard").tag(NewNoteAction.clipboard)
Text("Open Terminal $EDITOR").tag(NewNoteAction.editor)
}
.pickerStyle(.menu)
.frame(width: 200)
}
HStack {
Spacer()
.frame(width: labelWidth)
Text("Notes and uploads will be saved to this folder")
Text("Notes and uploads will be saved to this folder. Editor notes upload after the editor exits.")
.font(.caption)
.foregroundColor(.secondary)
.frame(width: 280, alignment: .leading)
@@ -166,6 +182,7 @@ public struct SettingsView: View {
apiToken: apiToken.isEmpty ? nil : apiToken,
syncFolder: syncFolder.isEmpty ? nil : syncFolder,
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
newNoteAction: newNoteAction,
autoSync: autoSync,
syncInterval: Int(syncInterval),
deviceId: config.deviceId,

View File

@@ -116,7 +116,7 @@ public struct StatusPopoverView: View {
label: "Note",
color: .green
) {
controller.createNoteFromClipboard()
controller.createNote()
}
ActionButton(

View File

@@ -189,7 +189,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
contextMenu.addItem(NSMenuItem(title: "Create Note from Clipboard", action: #selector(createNote), keyEquivalent: "n"))
contextMenu.addItem(NSMenuItem(title: "New Note", action: #selector(createNote), keyEquivalent: "n"))
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
@@ -253,7 +253,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func createNote() {
Task { @MainActor in
controller.createNoteFromClipboard()
controller.createNote()
}
}

View File

@@ -224,6 +224,12 @@ struct CLI {
config.syncFolder = value
case "--upload-folder":
config.defaultUploadFolder = value
case "--new-note":
guard let action = NewNoteAction(rawValue: value) else {
print("Invalid new note action: \(value). Use clipboard or editor.")
throw CLIError.invalidArguments
}
config.newNoteAction = action
default:
print("Unknown config option: \(arg)")
throw CLIError.invalidArguments
@@ -237,6 +243,7 @@ struct CLI {
print(" Server: \(config.serverURL)")
print(" Sync Folder: \(config.syncFolder ?? "(none)")")
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
print(" New Note: \(config.effectiveNewNoteAction.rawValue)")
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
}
@@ -382,6 +389,7 @@ struct CLI {
--token <token> Set the API token
--folder <path> Set the local folder used by sync
--upload-folder <path> Set the server folder for readable uploads
--new-note <action> Set New Note behavior: clipboard or editor
The menubar app and CLI use the same saved configuration.
""")

View File

@@ -0,0 +1,63 @@
#!/bin/zsh
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PACKAGE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PACKAGE_DIR"
product="${1:-CairnquireSync}"
case "$product" in
CairnquireSync)
signing_identifier="com.cairnquire.sync"
;;
cairnquire-cli)
signing_identifier="com.cairnquire.cli"
;;
*)
echo "Unsupported product: $product" >&2
echo "Usage: scripts/sign-dev.sh [CairnquireSync|cairnquire-cli]" >&2
exit 2
;;
esac
identity="${CAIRNQUIRE_CODESIGN_IDENTITY:-}"
if [[ -z "$identity" ]]; then
identity="$(
security find-identity -v -p codesigning \
| awk -F '"' '/Apple Development/ { print $2; exit }'
)"
fi
if [[ -z "$identity" ]]; then
echo "No Apple Development signing identity found." >&2
echo "Create one in Xcode, then verify with:" >&2
echo " security find-identity -v -p codesigning" >&2
exit 1
fi
swift build --product "$product"
executable=".build/debug/$product"
if [[ ! -f "$executable" ]]; then
executable="$(find .build -path "*/debug/$product" -type f -perm -111 | head -n 1)"
fi
if [[ -z "$executable" || ! -f "$executable" ]]; then
echo "Built executable not found for product: $product" >&2
exit 1
fi
codesign \
--force \
--sign "$identity" \
--identifier "$signing_identifier" \
--timestamp=none \
"$executable"
echo "Signed $executable"
codesign -dvvv --requirements - "$executable" 2>&1 | sed -n '1,80p'
echo
echo "Run the signed executable directly:"
echo " $executable"
echo
echo "Avoid 'swift run $product' after signing; it can rebuild and replace the signed executable."