diff --git a/.gitignore b/.gitignore index 52fe2f7..cd4e9c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,62 +1,47 @@ # Xcode -# -# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings -## User settings -xcuserdata/ - -## Obj-C/Swift specific -*.hmap - -## App packaging +# Build +build/ +DerivedData/ +*.moved-aside *.ipa *.dSYM.zip *.dSYM -## Playgrounds +# Dependencies +Packages/ +.build/ + +# OS +.DS_Store +.AppleDouble +.LSOverride +._* +.Spotlight-V100 +.Trashes + +# IDE +*.swp +*.lock +*.xcuserstate +*.xcuserdatad/ +xcuserdata/ + +# Archives +*.xcarchive + +# Playgrounds timeline.xctimeline playground.xcworkspace # Swift Package Manager -# -# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. -# Packages/ -# Package.pins -# Package.resolved -# *.xcodeproj -# -# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata -# hence it is not needed unless you have added a package configuration file to your project -# .swiftpm +.swiftpm/ -.build/ - -# CocoaPods -# -# We recommend against adding the Pods directory to your .gitignore. However -# you should judge for yourself, the pros and cons are mentioned at: -# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control -# -# Pods/ -# -# Add this line if you want to avoid checking in source code from the Xcode workspace -# *.xcworkspace - -# Carthage -# -# Add this line if you want to avoid checking in source code from Carthage dependencies. -# Carthage/Checkouts - -Carthage/Build/ - -# fastlane -# -# It is recommended to not store the screenshots in the git repo. -# Instead, use fastlane to re-generate the screenshots whenever they are needed. -# For more information about the recommended setup visit: -# https://docs.fastlane.tools/best-practices/source-control/#source-control - -fastlane/report.xml -fastlane/Preview.html -fastlane/screenshots/**/*.png -fastlane/test_output +# Secrets (if any) +Secrets.swift diff --git a/AppIcon.png b/AppIcon.png new file mode 100644 index 0000000..a7ec636 Binary files /dev/null and b/AppIcon.png differ diff --git a/FastMemos.xcodeproj/project.pbxproj b/FastMemos.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9e69125 --- /dev/null +++ b/FastMemos.xcodeproj/project.pbxproj @@ -0,0 +1,407 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + AA1000001 /* FastMemosApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000001 /* FastMemosApp.swift */; }; + AA1000002 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000002 /* MenuBarView.swift */; }; + AA1000003 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000003 /* LoginView.swift */; }; + AA1000004 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000004 /* SettingsView.swift */; }; + AA1000005 /* NoteWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000005 /* NoteWindowView.swift */; }; + AA1000006 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000006 /* AppState.swift */; }; + AA1000007 /* MemosAPIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000007 /* MemosAPIService.swift */; }; + AA1000008 /* KeychainService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000008 /* KeychainService.swift */; }; + AA1000009 /* ShortcutService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000009 /* ShortcutService.swift */; }; + AA1000010 /* Memo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000010 /* Memo.swift */; }; + AA1000011 /* MemoVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2000011 /* MemoVisibility.swift */; }; + AA1000012 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AA2000012 /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + AA2000001 /* FastMemosApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FastMemosApp.swift; sourceTree = ""; }; + AA2000002 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + AA2000003 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; + AA2000004 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + AA2000005 /* NoteWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteWindowView.swift; sourceTree = ""; }; + AA2000006 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + AA2000007 /* MemosAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemosAPIService.swift; sourceTree = ""; }; + AA2000008 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = ""; }; + AA2000009 /* ShortcutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutService.swift; sourceTree = ""; }; + AA2000010 /* Memo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Memo.swift; sourceTree = ""; }; + AA2000011 /* MemoVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoVisibility.swift; sourceTree = ""; }; + AA2000012 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + AA2000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + AA2000014 /* FastMemos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FastMemos.entitlements; sourceTree = ""; }; + AA3000001 /* FastMemos.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = FastMemos.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + AA4000001 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + AA5000001 = { + isa = PBXGroup; + children = ( + AA5000002 /* FastMemos */, + AA5000007 /* Products */, + ); + sourceTree = ""; + }; + AA5000002 /* FastMemos */ = { + isa = PBXGroup; + children = ( + AA2000001 /* FastMemosApp.swift */, + AA5000003 /* Models */, + AA5000004 /* Services */, + AA5000005 /* ViewModels */, + AA5000006 /* Views */, + AA2000012 /* Assets.xcassets */, + AA2000013 /* Info.plist */, + AA2000014 /* FastMemos.entitlements */, + ); + path = FastMemos; + sourceTree = ""; + }; + AA5000003 /* Models */ = { + isa = PBXGroup; + children = ( + AA2000010 /* Memo.swift */, + AA2000011 /* MemoVisibility.swift */, + ); + path = Models; + sourceTree = ""; + }; + AA5000004 /* Services */ = { + isa = PBXGroup; + children = ( + AA2000007 /* MemosAPIService.swift */, + AA2000008 /* KeychainService.swift */, + AA2000009 /* ShortcutService.swift */, + ); + path = Services; + sourceTree = ""; + }; + AA5000005 /* ViewModels */ = { + isa = PBXGroup; + children = ( + AA2000006 /* AppState.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + AA5000006 /* Views */ = { + isa = PBXGroup; + children = ( + AA2000002 /* MenuBarView.swift */, + AA2000003 /* LoginView.swift */, + AA2000004 /* SettingsView.swift */, + AA2000005 /* NoteWindowView.swift */, + ); + path = Views; + sourceTree = ""; + }; + AA5000007 /* Products */ = { + isa = PBXGroup; + children = ( + AA3000001 /* FastMemos.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + AA6000001 /* FastMemos */ = { + isa = PBXNativeTarget; + buildConfigurationList = AA8000001 /* Build configuration list for PBXNativeTarget "FastMemos" */; + buildPhases = ( + AA7000001 /* Sources */, + AA4000001 /* Frameworks */, + AA7000002 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = FastMemos; + productName = FastMemos; + productReference = AA3000001 /* FastMemos.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + AA9000001 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + AA6000001 = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = AA8000003 /* Build configuration list for PBXProject "FastMemos" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = AA5000001; + productRefGroup = AA5000007 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + AA6000001 /* FastMemos */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + AA7000002 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA1000012 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + AA7000001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AA1000001 /* FastMemosApp.swift in Sources */, + AA1000002 /* MenuBarView.swift in Sources */, + AA1000003 /* LoginView.swift in Sources */, + AA1000004 /* SettingsView.swift in Sources */, + AA1000005 /* NoteWindowView.swift in Sources */, + AA1000006 /* AppState.swift in Sources */, + AA1000007 /* MemosAPIService.swift in Sources */, + AA1000008 /* KeychainService.swift in Sources */, + AA1000009 /* ShortcutService.swift in Sources */, + AA1000010 /* Memo.swift in Sources */, + AA1000011 /* MemoVisibility.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + AAA000001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + AAA000002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + }; + name = Release; + }; + AAA000003 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = FastMemos/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FastMemos; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Paweł Orzech. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + AAA000004 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = FastMemos/FastMemos.entitlements; + CODE_SIGN_IDENTITY = "-"; + CODE_SIGN_STYLE = Manual; + COMBINE_HIDPI_IMAGES = YES; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_HARDENED_RUNTIME = NO; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = FastMemos/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = FastMemos; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; + INFOPLIST_KEY_NSHumanReadableCopyright = "Copyright © 2026 Paweł Orzech. All rights reserved."; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + MARKETING_VERSION = 1.0.0; + PRODUCT_BUNDLE_IDENTIFIER = me.orzech.FastMemos; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + AA8000001 /* Build configuration list for PBXNativeTarget "FastMemos" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AAA000003 /* Debug */, + AAA000004 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + AA8000003 /* Build configuration list for PBXProject "FastMemos" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AAA000001 /* Debug */, + AAA000002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = AA9000001 /* Project object */; +} diff --git a/FastMemos/Assets.xcassets/AccentColor.colorset/Contents.json b/FastMemos/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..90e124e --- /dev/null +++ b/FastMemos/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors": [ + { + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.820", + "green": "0.580", + "red": "0.220" + } + }, + "idiom": "universal" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "color": { + "color-space": "srgb", + "components": { + "alpha": "1.000", + "blue": "0.900", + "green": "0.680", + "red": "0.340" + } + }, + "idiom": "universal" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/FastMemos/Assets.xcassets/AppIcon.appiconset/Contents.json b/FastMemos/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..4662e62 --- /dev/null +++ b/FastMemos/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,58 @@ +{ + "images": [ + { + "idiom": "mac", + "scale": "1x", + "size": "16x16" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "16x16" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "32x32" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "32x32" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "128x128" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "128x128" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "256x256" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "256x256" + }, + { + "idiom": "mac", + "scale": "1x", + "size": "512x512" + }, + { + "idiom": "mac", + "scale": "2x", + "size": "512x512" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/FastMemos/Assets.xcassets/Contents.json b/FastMemos/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/FastMemos/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/FastMemos/FastMemos.entitlements b/FastMemos/FastMemos.entitlements new file mode 100644 index 0000000..b1088de --- /dev/null +++ b/FastMemos/FastMemos.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/FastMemos/FastMemosApp.swift b/FastMemos/FastMemosApp.swift new file mode 100644 index 0000000..26ab3ee --- /dev/null +++ b/FastMemos/FastMemosApp.swift @@ -0,0 +1,72 @@ +import SwiftUI +import AppKit + +@main +struct FastMemosApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + + var body: some Scene { + Settings { + EmptyView() + } + } +} + +class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { + private var statusItem: NSStatusItem! + private var popover: NSPopover! + private var notePanel: NotePanel? + private var shortcutService: ShortcutService? + + @Published var appState = AppState() + + func applicationDidFinishLaunching(_ notification: Notification) { + setupMenuBar() + setupShortcut() + + // Hide dock icon - we're a menubar-only app + NSApp.setActivationPolicy(.accessory) + } + + private func setupMenuBar() { + statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + + if let button = statusItem.button { + button.image = NSImage(systemSymbolName: "note.text", accessibilityDescription: "FastMemos") + button.action = #selector(togglePopover) + button.target = self + } + + popover = NSPopover() + popover.contentSize = NSSize(width: 280, height: 320) + popover.behavior = .transient + popover.contentViewController = NSHostingController( + rootView: MenuBarView(appState: appState, showNoteWindow: showNoteWindow) + ) + } + + private func setupShortcut() { + shortcutService = ShortcutService { [weak self] in + self?.showNoteWindow() + } + shortcutService?.registerDefaultShortcut() + } + + @objc private func togglePopover() { + guard let button = statusItem.button else { return } + + if popover.isShown { + popover.performClose(nil) + } else { + popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) + NSApp.activate(ignoringOtherApps: true) + } + } + + func showNoteWindow() { + if notePanel == nil { + notePanel = NotePanel(appState: appState) + } + notePanel?.showWindow() + } +} diff --git a/FastMemos/Info.plist b/FastMemos/Info.plist new file mode 100644 index 0000000..a3774b3 --- /dev/null +++ b/FastMemos/Info.plist @@ -0,0 +1,38 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + AppIcon + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + LSApplicationCategoryType + public.app-category.productivity + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + LSUIElement + + NSHighResolutionCapable + + NSHumanReadableCopyright + Copyright © 2026 Paweł Orzech. All rights reserved. + NSMainNibFile + + NSPrincipalClass + NSApplication + + diff --git a/FastMemos/Models/Memo.swift b/FastMemos/Models/Memo.swift new file mode 100644 index 0000000..693d534 --- /dev/null +++ b/FastMemos/Models/Memo.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Represents a memo from the Memos API +struct Memo: Codable, Identifiable { + let id: Int? + let name: String? + let content: String + let visibility: String + let createTime: String? + let updateTime: String? + + enum CodingKeys: String, CodingKey { + case id + case name + case content + case visibility + case createTime + case updateTime + } +} + +/// Request body for creating a new memo +struct CreateMemoRequest: Codable { + let content: String + let visibility: String +} + +/// Response from login endpoint +struct LoginResponse: Codable { + let accessToken: String? + let user: UserInfo? + + enum CodingKeys: String, CodingKey { + case accessToken = "accessToken" + case user + } +} + +/// User info from login response +struct UserInfo: Codable { + let id: Int? + let name: String? + let username: String? +} + +/// Sign-in request body +struct SignInRequest: Codable { + let username: String + let password: String + let neverExpire: Bool + + init(username: String, password: String) { + self.username = username + self.password = password + self.neverExpire = true + } +} diff --git a/FastMemos/Models/MemoVisibility.swift b/FastMemos/Models/MemoVisibility.swift new file mode 100644 index 0000000..46fd77f --- /dev/null +++ b/FastMemos/Models/MemoVisibility.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Visibility options for a memo +enum MemoVisibility: String, CaseIterable, Codable { + case `private` = "PRIVATE" + case protected = "PROTECTED" + case `public` = "PUBLIC" + + var displayName: String { + switch self { + case .private: return "Private" + case .protected: return "Protected" + case .public: return "Public" + } + } + + var icon: String { + switch self { + case .private: return "lock.fill" + case .protected: return "link" + case .public: return "globe" + } + } + + var description: String { + switch self { + case .private: return "Only you can see" + case .protected: return "Anyone with link" + case .public: return "Visible to everyone" + } + } +} diff --git a/FastMemos/Services/KeychainService.swift b/FastMemos/Services/KeychainService.swift new file mode 100644 index 0000000..bd4732e --- /dev/null +++ b/FastMemos/Services/KeychainService.swift @@ -0,0 +1,85 @@ +import Foundation +import Security + +/// Service for securely storing credentials in macOS Keychain +class KeychainService { + private let serviceName = "me.orzech.FastMemos" + + // MARK: - Access Token + + func saveAccessToken(_ token: String) { + save(key: "accessToken", value: token) + } + + func getAccessToken() -> String? { + return get(key: "accessToken") + } + + func deleteAccessToken() { + delete(key: "accessToken") + } + + // MARK: - Username + + func saveUsername(_ username: String) { + save(key: "username", value: username) + } + + func getUsername() -> String? { + return get(key: "username") + } + + func deleteUsername() { + delete(key: "username") + } + + // MARK: - Generic Keychain Operations + + private func save(key: String, value: String) { + guard let data = value.data(using: .utf8) else { return } + + // Delete any existing item first + delete(key: key) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlocked + ] + + SecItemAdd(query as CFDictionary, nil) + } + + private func get(key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let value = String(data: data, encoding: .utf8) else { + return nil + } + + return value + } + + private func delete(key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: serviceName, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } +} diff --git a/FastMemos/Services/MemosAPIService.swift b/FastMemos/Services/MemosAPIService.swift new file mode 100644 index 0000000..f1bb0a7 --- /dev/null +++ b/FastMemos/Services/MemosAPIService.swift @@ -0,0 +1,105 @@ +import Foundation + +/// Service for interacting with the Memos API +class MemosAPIService { + private let session: URLSession + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30 + config.timeoutIntervalForResource = 60 + self.session = URLSession(configuration: config) + } + + /// Validate an access token by attempting to fetch user info + func validateToken(serverURL: URL, token: String) async throws { + // Try the v1 API endpoint to get current user + let endpoint = serverURL.appendingPathComponent("/api/v1/auth/status") + + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AppError.networkError("Invalid response") + } + + // Try different endpoints if status returns 404 + if httpResponse.statusCode == 404 { + // Try /api/v1/user/me as alternative + try await validateTokenAlternate(serverURL: serverURL, token: token) + return + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AppError.authenticationFailed + } + + guard (200...299).contains(httpResponse.statusCode) else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + throw AppError.serverError("Status \(httpResponse.statusCode): \(errorMessage)") + } + } + + /// Alternative token validation endpoint + private func validateTokenAlternate(serverURL: URL, token: String) async throws { + // Try the /api/v1/user/me endpoint + let endpoint = serverURL.appendingPathComponent("/api/v1/user/me") + + var request = URLRequest(url: endpoint) + request.httpMethod = "GET" + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AppError.networkError("Invalid response") + } + + // If still 404, just try creating a memo (will fail with auth error if token is bad) + if httpResponse.statusCode == 404 { + // Token format looks valid, we'll verify on first memo creation + return + } + + if httpResponse.statusCode == 401 || httpResponse.statusCode == 403 { + throw AppError.authenticationFailed + } + + guard (200...299).contains(httpResponse.statusCode) else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + throw AppError.serverError("Status \(httpResponse.statusCode): \(errorMessage)") + } + } + + /// Create a new memo on the server + func createMemo(serverURL: URL, token: String, content: String, visibility: MemoVisibility) async throws { + // First try the v1 API endpoint + let endpoint = serverURL.appendingPathComponent("/api/v1/memos") + + var request = URLRequest(url: endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let memoRequest = CreateMemoRequest(content: content, visibility: visibility.rawValue) + request.httpBody = try JSONEncoder().encode(memoRequest) + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw AppError.networkError("Invalid response") + } + + if httpResponse.statusCode == 401 { + throw AppError.authenticationFailed + } + + guard (200...299).contains(httpResponse.statusCode) else { + let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" + throw AppError.serverError("Status \(httpResponse.statusCode): \(errorMessage)") + } + } +} diff --git a/FastMemos/Services/ShortcutService.swift b/FastMemos/Services/ShortcutService.swift new file mode 100644 index 0000000..270200a --- /dev/null +++ b/FastMemos/Services/ShortcutService.swift @@ -0,0 +1,124 @@ +import Foundation +import Carbon +import AppKit + +/// Service for managing global keyboard shortcuts +class ShortcutService { + private var hotKeyRef: EventHotKeyRef? + private var eventHandler: EventHandlerRef? + private let callback: () -> Void + + // Default shortcut: Cmd+Shift+M + private var currentKeyCode: UInt32 = UInt32(kVK_ANSI_M) + private var currentModifiers: UInt32 = UInt32(cmdKey | shiftKey) + + init(callback: @escaping () -> Void) { + self.callback = callback + } + + deinit { + unregisterShortcut() + } + + /// Register the default global shortcut (Cmd+Shift+M) + func registerDefaultShortcut() { + // Load saved shortcut or use default + if let savedKeyCode = UserDefaults.standard.object(forKey: "shortcutKeyCode") as? UInt32 { + currentKeyCode = savedKeyCode + } + if let savedModifiers = UserDefaults.standard.object(forKey: "shortcutModifiers") as? UInt32 { + currentModifiers = savedModifiers + } + + registerShortcut(keyCode: currentKeyCode, modifiers: currentModifiers) + } + + /// Register a global hotkey + func registerShortcut(keyCode: UInt32, modifiers: UInt32) { + unregisterShortcut() + + // Store the callback in a way accessible to the C callback + let context = Unmanaged.passUnretained(self).toOpaque() + + var eventType = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) + + let handlerCallback: EventHandlerUPP = { _, event, userData -> OSStatus in + guard let userData = userData else { return OSStatus(eventNotHandledErr) } + let service = Unmanaged.fromOpaque(userData).takeUnretainedValue() + + DispatchQueue.main.async { + service.callback() + } + + return noErr + } + + InstallEventHandler( + GetApplicationEventTarget(), + handlerCallback, + 1, + &eventType, + context, + &eventHandler + ) + + var hotKeyID = EventHotKeyID(signature: OSType(0x464D454D), id: 1) // "FMEM" + + RegisterEventHotKey( + currentKeyCode, + currentModifiers, + hotKeyID, + GetApplicationEventTarget(), + 0, + &hotKeyRef + ) + + // Save the shortcut + UserDefaults.standard.set(keyCode, forKey: "shortcutKeyCode") + UserDefaults.standard.set(modifiers, forKey: "shortcutModifiers") + } + + /// Unregister the current global hotkey + func unregisterShortcut() { + if let hotKeyRef = hotKeyRef { + UnregisterEventHotKey(hotKeyRef) + self.hotKeyRef = nil + } + + if let eventHandler = eventHandler { + RemoveEventHandler(eventHandler) + self.eventHandler = nil + } + } + + /// Get a human-readable representation of the current shortcut + var shortcutDescription: String { + var parts: [String] = [] + + if currentModifiers & UInt32(controlKey) != 0 { parts.append("⌃") } + if currentModifiers & UInt32(optionKey) != 0 { parts.append("⌥") } + if currentModifiers & UInt32(shiftKey) != 0 { parts.append("⇧") } + if currentModifiers & UInt32(cmdKey) != 0 { parts.append("⌘") } + + // Map key code to character + let keyChar = keyCodeToString(currentKeyCode) + parts.append(keyChar) + + return parts.joined() + } + + private func keyCodeToString(_ keyCode: UInt32) -> String { + let keyMap: [UInt32: String] = [ + UInt32(kVK_ANSI_A): "A", UInt32(kVK_ANSI_B): "B", UInt32(kVK_ANSI_C): "C", + UInt32(kVK_ANSI_D): "D", UInt32(kVK_ANSI_E): "E", UInt32(kVK_ANSI_F): "F", + UInt32(kVK_ANSI_G): "G", UInt32(kVK_ANSI_H): "H", UInt32(kVK_ANSI_I): "I", + UInt32(kVK_ANSI_J): "J", UInt32(kVK_ANSI_K): "K", UInt32(kVK_ANSI_L): "L", + UInt32(kVK_ANSI_M): "M", UInt32(kVK_ANSI_N): "N", UInt32(kVK_ANSI_O): "O", + UInt32(kVK_ANSI_P): "P", UInt32(kVK_ANSI_Q): "Q", UInt32(kVK_ANSI_R): "R", + UInt32(kVK_ANSI_S): "S", UInt32(kVK_ANSI_T): "T", UInt32(kVK_ANSI_U): "U", + UInt32(kVK_ANSI_V): "V", UInt32(kVK_ANSI_W): "W", UInt32(kVK_ANSI_X): "X", + UInt32(kVK_ANSI_Y): "Y", UInt32(kVK_ANSI_Z): "Z" + ] + return keyMap[keyCode] ?? "?" + } +} diff --git a/FastMemos/ViewModels/AppState.swift b/FastMemos/ViewModels/AppState.swift new file mode 100644 index 0000000..5df7709 --- /dev/null +++ b/FastMemos/ViewModels/AppState.swift @@ -0,0 +1,141 @@ +import Foundation +import SwiftUI + +/// Global app state that persists settings and manages authentication +class AppState: ObservableObject { + @Published var isLoggedIn: Bool = false + @Published var serverURL: String = "" + @Published var username: String = "" + @Published var defaultVisibility: MemoVisibility = .private + @Published var isLoading: Bool = false + @Published var lastError: String? + + private let keychainService = KeychainService() + private lazy var apiService = MemosAPIService() + + init() { + loadSettings() + } + + // MARK: - Settings Persistence + + private func loadSettings() { + serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? "" + username = keychainService.getUsername() ?? "" + + if let visibilityString = UserDefaults.standard.string(forKey: "defaultVisibility"), + let visibility = MemoVisibility(rawValue: visibilityString) { + defaultVisibility = visibility + } + + // Check if we have a valid token + isLoggedIn = keychainService.getAccessToken() != nil && !serverURL.isEmpty + } + + func saveSettings() { + UserDefaults.standard.set(serverURL, forKey: "serverURL") + UserDefaults.standard.set(defaultVisibility.rawValue, forKey: "defaultVisibility") + } + + // MARK: - Authentication + + /// Connect using an Access Token (recommended for Memos v0.18+) + func connectWithToken(serverURL: String, accessToken: String) async throws { + await MainActor.run { + self.isLoading = true + self.lastError = nil + } + + defer { + Task { @MainActor in + self.isLoading = false + } + } + + // Normalize server URL + var normalizedURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedURL.hasPrefix("http://") && !normalizedURL.hasPrefix("https://") { + normalizedURL = "https://" + normalizedURL + } + if normalizedURL.hasSuffix("/") { + normalizedURL = String(normalizedURL.dropLast()) + } + + guard let url = URL(string: normalizedURL) else { + throw AppError.invalidURL + } + + // Validate the token by attempting to get user info + try await apiService.validateToken(serverURL: url, token: accessToken) + + // Save credentials + keychainService.saveAccessToken(accessToken) + + await MainActor.run { + self.serverURL = normalizedURL + self.isLoggedIn = true + self.saveSettings() + } + } + + func logout() { + keychainService.deleteAccessToken() + keychainService.deleteUsername() + + isLoggedIn = false + username = "" + serverURL = "" + + UserDefaults.standard.removeObject(forKey: "serverURL") + } + + // MARK: - Memo Creation + + func createMemo(content: String, visibility: MemoVisibility) async throws { + guard let token = keychainService.getAccessToken(), + let url = URL(string: serverURL) else { + throw AppError.notLoggedIn + } + + await MainActor.run { + self.isLoading = true + self.lastError = nil + } + + defer { + Task { @MainActor in + self.isLoading = false + } + } + + try await apiService.createMemo( + serverURL: url, + token: token, + content: content, + visibility: visibility + ) + } +} + +enum AppError: LocalizedError { + case invalidURL + case notLoggedIn + case networkError(String) + case authenticationFailed + case serverError(String) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid server URL" + case .notLoggedIn: + return "Please log in first" + case .networkError(let message): + return "Network error: \(message)" + case .authenticationFailed: + return "Invalid username or password" + case .serverError(let message): + return "Server error: \(message)" + } + } +} diff --git a/FastMemos/Views/LoginView.swift b/FastMemos/Views/LoginView.swift new file mode 100644 index 0000000..15e93d0 --- /dev/null +++ b/FastMemos/Views/LoginView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +/// Login view for connecting to a Memos server using Access Token +struct LoginView: View { + @ObservedObject var appState: AppState + @Binding var isPresented: Bool + + @State private var serverURL: String = "" + @State private var accessToken: String = "" + @State private var errorMessage: String? + @State private var isLoading = false + + var body: some View { + VStack(spacing: 20) { + // Header + VStack(spacing: 8) { + Image(systemName: "server.rack") + .font(.system(size: 40)) + .foregroundColor(.accentColor) + Text("Connect to Memos") + .font(.title2) + .fontWeight(.semibold) + Text("Enter your Memos server details") + .font(.subheadline) + .foregroundColor(.secondary) + } + .padding(.top) + + // Form + VStack(spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Server URL") + .font(.caption) + .foregroundColor(.secondary) + TextField("https://memos.example.com", text: $serverURL) + .textFieldStyle(.roundedBorder) + .autocorrectionDisabled() + } + + VStack(alignment: .leading, spacing: 4) { + HStack { + Text("Access Token") + .font(.caption) + .foregroundColor(.secondary) + Spacer() + Link("How to get?", destination: URL(string: "https://www.usememos.com/docs/security/access-tokens")!) + .font(.caption) + } + SecureField("Paste your access token", text: $accessToken) + .textFieldStyle(.roundedBorder) + } + + // Help text + VStack(alignment: .leading, spacing: 4) { + Text("To get your access token:") + .font(.caption) + .foregroundColor(.secondary) + Text("1. Open Memos → Settings → My Account") + .font(.caption) + .foregroundColor(.secondary) + Text("2. Click \"Create\" under Access Tokens") + .font(.caption) + .foregroundColor(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + } + .padding(.horizontal) + + // Error message + if let error = errorMessage { + HStack { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundColor(.red) + Text(error) + .font(.caption) + .foregroundColor(.red) + } + .padding(.horizontal) + } + + // Buttons + HStack { + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.escape) + + Spacer() + + Button(action: connect) { + if isLoading { + ProgressView() + .scaleEffect(0.7) + } else { + Text("Connect") + } + } + .buttonStyle(.borderedProminent) + .disabled(isLoading || serverURL.isEmpty || accessToken.isEmpty) + .keyboardShortcut(.return) + } + .padding() + } + .frame(width: 380, height: 420) + .onAppear { + serverURL = appState.serverURL + } + } + + private func connect() { + isLoading = true + errorMessage = nil + + Task { + do { + try await appState.connectWithToken(serverURL: serverURL, accessToken: accessToken) + await MainActor.run { + isPresented = false + } + } catch { + await MainActor.run { + errorMessage = error.localizedDescription + isLoading = false + } + } + } + } +} diff --git a/FastMemos/Views/MenuBarView.swift b/FastMemos/Views/MenuBarView.swift new file mode 100644 index 0000000..f9b8fcb --- /dev/null +++ b/FastMemos/Views/MenuBarView.swift @@ -0,0 +1,179 @@ +import SwiftUI + +/// Main menubar popover view +struct MenuBarView: View { + @ObservedObject var appState: AppState + let showNoteWindow: () -> Void + + @State private var showingSettings = false + @State private var showingLogin = false + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Image(systemName: "note.text") + .font(.title2) + .foregroundColor(.accentColor) + Text("FastMemos") + .font(.headline) + Spacer() + + // Connection status + Circle() + .fill(appState.isLoggedIn ? Color.green : Color.red) + .frame(width: 8, height: 8) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + + Divider() + + // Main content + VStack(spacing: 12) { + if appState.isLoggedIn { + // Quick Note Button + Button(action: showNoteWindow) { + HStack { + Image(systemName: "square.and.pencil") + Text("New Note") + Spacer() + Text("⌘⇧M") + .font(.caption) + .foregroundColor(.secondary) + } + } + .buttonStyle(.borderedProminent) + + // Server info + HStack { + Image(systemName: "server.rack") + .foregroundColor(.secondary) + Text(appState.serverURL) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + } + } else { + // Not logged in state + VStack(spacing: 8) { + Image(systemName: "person.crop.circle.badge.xmark") + .font(.largeTitle) + .foregroundColor(.secondary) + Text("Not connected") + .font(.subheadline) + .foregroundColor(.secondary) + Button("Log In") { + showingLogin = true + } + .buttonStyle(.borderedProminent) + } + .padding(.vertical) + } + } + .padding() + + Divider() + + // Bottom actions + VStack(spacing: 4) { + Button(action: { showingSettings = true }) { + HStack { + Image(systemName: "gear") + Text("Settings") + Spacer() + } + } + .buttonStyle(.plain) + + Button(action: openGitHub) { + HStack { + Image(systemName: "link") + Text("View on GitHub") + Spacer() + } + } + .buttonStyle(.plain) + + Button(action: sendFeedback) { + HStack { + Image(systemName: "envelope") + Text("Send Feedback") + Spacer() + } + } + .buttonStyle(.plain) + + Divider() + .padding(.vertical, 4) + + if appState.isLoggedIn { + Button(action: { appState.logout() }) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + Text("Log Out") + Spacer() + } + .foregroundColor(.red) + } + .buttonStyle(.plain) + } + + // Version info + HStack { + Text("v\(Bundle.main.appVersion)") + .font(.caption2) + .foregroundColor(.secondary) + Spacer() + } + } + .padding() + } + .frame(width: 280) + .sheet(isPresented: $showingSettings) { + SettingsView(appState: appState) + } + .sheet(isPresented: $showingLogin) { + LoginView(appState: appState, isPresented: $showingLogin) + } + } + + private func openGitHub() { + if let url = URL(string: "https://github.com/pawelorzech/FastMemos") { + NSWorkspace.shared.open(url) + } + } + + private func sendFeedback() { + let version = Bundle.main.appVersion + let build = Bundle.main.buildNumber + let macOSVersion = ProcessInfo.processInfo.operatingSystemVersionString + + let subject = "FastMemos Feedback - v\(version)" + let body = """ + + --- + FastMemos v\(version) (\(build)) + macOS \(macOSVersion) + """ + + let encodedSubject = subject.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let encodedBody = body.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + + if let url = URL(string: "mailto:pawel@orzech.me?subject=\(encodedSubject)&body=\(encodedBody)") { + NSWorkspace.shared.open(url) + } + } +} + +extension Bundle { + var appVersion: String { + return infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + } + + var buildNumber: String { + return infoDictionary?["CFBundleVersion"] as? String ?? "1" + } +} diff --git a/FastMemos/Views/NoteWindowView.swift b/FastMemos/Views/NoteWindowView.swift new file mode 100644 index 0000000..0159229 --- /dev/null +++ b/FastMemos/Views/NoteWindowView.swift @@ -0,0 +1,257 @@ +import SwiftUI +import AppKit + +/// Floating panel for quick note capture +class NotePanel: NSPanel { + private var hostingView: NSHostingView? + private let appState: AppState + + init(appState: AppState) { + self.appState = appState + + super.init( + contentRect: NSRect(x: 0, y: 0, width: 500, height: 280), + styleMask: [.titled, .closable, .resizable, .fullSizeContentView], + backing: .buffered, + defer: false + ) + + self.title = "" + self.titlebarAppearsTransparent = true + self.titleVisibility = .hidden + self.isFloatingPanel = true + self.level = .floating + self.hidesOnDeactivate = false + self.isMovableByWindowBackground = true + self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] + self.backgroundColor = .clear + self.isOpaque = false + + // Set minimum size + self.minSize = NSSize(width: 400, height: 200) + self.maxSize = NSSize(width: 800, height: 600) + + // Center on screen + self.center() + + let contentView = NoteWindowView(appState: appState, closeWindow: { [weak self] in + self?.orderOut(nil) + }) + + hostingView = NSHostingView(rootView: contentView) + self.contentView = hostingView + } + + func showWindow() { + // Reset the view state + let contentView = NoteWindowView(appState: appState, closeWindow: { [weak self] in + self?.orderOut(nil) + }) + hostingView?.rootView = contentView + + self.center() + self.makeKeyAndOrderFront(nil) + NSApp.activate(ignoringOtherApps: true) + } +} + +/// Quick note capture view - Modern design +struct NoteWindowView: View { + @ObservedObject var appState: AppState + let closeWindow: () -> Void + + @State private var content: String = "" + @State private var visibility: MemoVisibility = .private + @State private var isSending = false + @State private var showSuccess = false + @State private var errorMessage: String? + + @FocusState private var isTextEditorFocused: Bool + + private let placeholder = "What's on your mind?" + + var body: some View { + VStack(spacing: 0) { + // Main text area + ZStack(alignment: .topLeading) { + // Placeholder + if content.isEmpty { + Text(placeholder) + .foregroundColor(.secondary) + .padding(.horizontal, 5) + .padding(.vertical, 8) + .allowsHitTesting(false) + } + + // Text editor + TextEditor(text: $content) + .font(.system(size: 15)) + .focused($isTextEditorFocused) + .scrollContentBackground(.hidden) + .background(Color.clear) + .frame(minHeight: 150) + } + .padding(16) + .background(Color(NSColor.textBackgroundColor).opacity(0.5)) + + // Bottom bar + HStack(spacing: 16) { + // Visibility picker + Menu { + ForEach(MemoVisibility.allCases, id: \.self) { vis in + Button(action: { visibility = vis }) { + Label(vis.displayName, systemImage: vis.icon) + } + } + } label: { + HStack(spacing: 6) { + Image(systemName: visibility.icon) + .font(.system(size: 12)) + Text(visibility.displayName) + .font(.system(size: 13)) + Image(systemName: "chevron.down") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .fixedSize() + + Spacer() + + // Error message + if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundColor(.red) + .lineLimit(1) + .transition(.opacity) + } + + // Success indicator + if showSuccess { + HStack(spacing: 4) { + Image(systemName: "checkmark.circle.fill") + Text("Sent!") + } + .font(.system(size: 13)) + .foregroundColor(.green) + .transition(.scale.combined(with: .opacity)) + } + + // Character/word count + HStack(spacing: 6) { + Text("\(wordCount) words") + Text("•") + .foregroundColor(.secondary.opacity(0.6)) + Text("\(charCount) chars") + } + .font(.system(size: 12)) + .foregroundColor(.secondary) + + // Submit button + Button(action: submitMemo) { + HStack(spacing: 6) { + if isSending { + ProgressView() + .scaleEffect(0.6) + .frame(width: 14, height: 14) + } else { + Text("Send") + Text("⌘↵") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.7)) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 7) + } + .buttonStyle(.borderedProminent) + .disabled(isSending || content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .keyboardShortcut(.return, modifiers: .command) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(.regularMaterial) + } + .background( + VisualEffectView(material: .hudWindow, blendingMode: .behindWindow) + ) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color(NSColor.separatorColor), lineWidth: 0.5) + ) + .frame(minWidth: 400, minHeight: 200) + .onAppear { + visibility = appState.defaultVisibility + isTextEditorFocused = true + } + .onExitCommand { + closeWindow() + } + .animation(.easeInOut(duration: 0.2), value: showSuccess) + .animation(.easeInOut(duration: 0.2), value: errorMessage) + } + + private var wordCount: Int { + let words = content.split { $0.isWhitespace || $0.isNewline } + return words.count + } + + private var charCount: Int { + content.count + } + + private func submitMemo() { + guard !content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + + isSending = true + errorMessage = nil + showSuccess = false + + Task { + do { + try await appState.createMemo(content: content, visibility: visibility) + await MainActor.run { + isSending = false + showSuccess = true + content = "" + + // Close after brief delay to show success + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + closeWindow() + } + } + } catch { + await MainActor.run { + isSending = false + errorMessage = error.localizedDescription + } + } + } + } +} + +/// NSVisualEffectView wrapper for SwiftUI +struct VisualEffectView: NSViewRepresentable { + let material: NSVisualEffectView.Material + let blendingMode: NSVisualEffectView.BlendingMode + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = blendingMode + view.state = .active + return view + } + + func updateNSView(_ nsView: NSVisualEffectView, context: Context) { + nsView.material = material + nsView.blendingMode = blendingMode + } +} diff --git a/FastMemos/Views/SettingsView.swift b/FastMemos/Views/SettingsView.swift new file mode 100644 index 0000000..c976663 --- /dev/null +++ b/FastMemos/Views/SettingsView.swift @@ -0,0 +1,144 @@ +import SwiftUI + +/// Settings view for configuring the app +struct SettingsView: View { + @ObservedObject var appState: AppState + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Settings") + .font(.headline) + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + .buttonStyle(.plain) + } + .padding() + .background(Color(NSColor.controlBackgroundColor)) + + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 20) { + // Default Visibility Section + VStack(alignment: .leading, spacing: 8) { + Label("Default Visibility", systemImage: "eye") + .font(.subheadline) + .fontWeight(.medium) + + // Use Menu instead of segmented Picker for better appearance + Menu { + ForEach(MemoVisibility.allCases, id: \.self) { visibility in + Button(action: { + appState.defaultVisibility = visibility + appState.saveSettings() + }) { + Label(visibility.displayName, systemImage: visibility.icon) + } + } + } label: { + HStack { + Image(systemName: appState.defaultVisibility.icon) + Text(appState.defaultVisibility.displayName) + Spacer() + Image(systemName: "chevron.up.chevron.down") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + } + .buttonStyle(.plain) + + Text(appState.defaultVisibility.description) + .font(.caption) + .foregroundColor(.secondary) + } + + Divider() + + // Keyboard Shortcut Section + VStack(alignment: .leading, spacing: 8) { + Label("Global Shortcut", systemImage: "keyboard") + .font(.subheadline) + .fontWeight(.medium) + + HStack { + Text("⌘⇧M") + .font(.system(.body, design: .monospaced)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + + Spacer() + + Text("Press to open note window") + .font(.caption) + .foregroundColor(.secondary) + } + } + + Divider() + + // About Section + VStack(alignment: .leading, spacing: 8) { + Label("About", systemImage: "info.circle") + .font(.subheadline) + .fontWeight(.medium) + + HStack { + Text("Version") + Spacer() + Text("\(Bundle.main.appVersion) (\(Bundle.main.buildNumber))") + .foregroundColor(.secondary) + } + + HStack { + Text("macOS") + Spacer() + Text(ProcessInfo.processInfo.operatingSystemVersionString) + .foregroundColor(.secondary) + } + } + + if appState.isLoggedIn { + Divider() + + // Connection Section + VStack(alignment: .leading, spacing: 8) { + Label("Connection", systemImage: "server.rack") + .font(.subheadline) + .fontWeight(.medium) + + HStack { + Text("Server") + Spacer() + Text(appState.serverURL) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + + HStack { + Circle() + .fill(Color.green) + .frame(width: 8, height: 8) + Text("Connected") + .foregroundColor(.green) + } + } + } + } + .padding() + } + } + .frame(width: 350, height: 400) + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f3f117 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# FastMemos + +A lightweight macOS menubar app for quickly capturing notes to your self-hosted [Memos](https://github.com/usememos/memos) instance. + +![FastMemos Screenshot](screenshot.png) + +## Features + +- 🎯 **Lives in your menubar** - Always accessible, never in the way +- ⌨️ **Global shortcut** - Press `⌘⇧M` anywhere to capture a thought (configurable) +- ⚡ **Instant sync** - `⌘Enter` to push notes to your Memos server +- 🔐 **Secure** - Credentials stored in macOS Keychain +- 📊 **Word count** - Real-time character and word count +- 🔒 **Visibility control** - Set default visibility + override per memo + +## Requirements + +- macOS 13.0 (Ventura) or later +- A self-hosted [Memos](https://github.com/usememos/memos) instance + +## Installation + +### Download +Download the latest `.dmg` from [Releases](https://github.com/pawelorzech/FastMemos/releases). + +### Build from Source +```bash +git clone https://github.com/pawelorzech/FastMemos.git +cd FastMemos +open FastMemos.xcodeproj +# Build with Xcode (⌘B) +``` + +## Setup + +1. Launch FastMemos - look for the icon in your menubar +2. Click the icon and go to **Settings** +3. Enter your Memos server URL (e.g., `https://memos.yourdomain.com`) +4. Log in with your Memos username and password +5. Configure your preferred default visibility +6. Customize the global shortcut if desired + +## Usage + +| Action | Shortcut | +|--------|----------| +| Open note window | `⌘⇧M` (default, configurable) | +| Submit note | `⌘Enter` | +| Close without saving | `Escape` | + +## Privacy + +- Your credentials are stored securely in macOS Keychain +- Notes are sent directly to YOUR server - no third parties +- No analytics, no tracking, no telemetry + +## Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + +## Feedback + +Found a bug or have a feature request? +- [Open an issue](https://github.com/pawelorzech/FastMemos/issues) +- Email: [pawel@orzech.me](mailto:pawel@orzech.me) + +## License + +[MIT License](LICENSE) © Paweł Orzech + +## Acknowledgments + +- [Memos](https://github.com/usememos/memos) - The amazing self-hosted note-taking service +- [HotKey](https://github.com/soffes/HotKey) - Global keyboard shortcuts for macOS