accounts and email
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -116,7 +116,7 @@ public struct StatusPopoverView: View {
|
||||
label: "Note",
|
||||
color: .green
|
||||
) {
|
||||
controller.createNoteFromClipboard()
|
||||
controller.createNote()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
""")
|
||||
|
||||
63
apps/macos-sync/scripts/sign-dev.sh
Executable file
63
apps/macos-sync/scripts/sign-dev.sh
Executable 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."
|
||||
Reference in New Issue
Block a user