feat: Implement initial application structure, Memos API integration, and user interface for memo management.

This commit is contained in:
Paweł Orzech 2026-01-18 02:05:00 +00:00
parent 61aa182e38
commit fe3872165a
No known key found for this signature in database
20 changed files with 1994 additions and 51 deletions

87
.gitignore vendored
View file

@ -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

BIN
AppIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 KiB

View file

@ -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 = "<group>"; };
AA2000002 /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
AA2000003 /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = "<group>"; };
AA2000004 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
AA2000005 /* NoteWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteWindowView.swift; sourceTree = "<group>"; };
AA2000006 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
AA2000007 /* MemosAPIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemosAPIService.swift; sourceTree = "<group>"; };
AA2000008 /* KeychainService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainService.swift; sourceTree = "<group>"; };
AA2000009 /* ShortcutService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutService.swift; sourceTree = "<group>"; };
AA2000010 /* Memo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Memo.swift; sourceTree = "<group>"; };
AA2000011 /* MemoVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoVisibility.swift; sourceTree = "<group>"; };
AA2000012 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
AA2000013 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AA2000014 /* FastMemos.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = FastMemos.entitlements; sourceTree = "<group>"; };
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 = "<group>";
};
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 = "<group>";
};
AA5000003 /* Models */ = {
isa = PBXGroup;
children = (
AA2000010 /* Memo.swift */,
AA2000011 /* MemoVisibility.swift */,
);
path = Models;
sourceTree = "<group>";
};
AA5000004 /* Services */ = {
isa = PBXGroup;
children = (
AA2000007 /* MemosAPIService.swift */,
AA2000008 /* KeychainService.swift */,
AA2000009 /* ShortcutService.swift */,
);
path = Services;
sourceTree = "<group>";
};
AA5000005 /* ViewModels */ = {
isa = PBXGroup;
children = (
AA2000006 /* AppState.swift */,
);
path = ViewModels;
sourceTree = "<group>";
};
AA5000006 /* Views */ = {
isa = PBXGroup;
children = (
AA2000002 /* MenuBarView.swift */,
AA2000003 /* LoginView.swift */,
AA2000004 /* SettingsView.swift */,
AA2000005 /* NoteWindowView.swift */,
);
path = Views;
sourceTree = "<group>";
};
AA5000007 /* Products */ = {
isa = PBXGroup;
children = (
AA3000001 /* FastMemos.app */,
);
name = Products;
sourceTree = "<group>";
};
/* 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 */;
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>

View file

@ -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()
}
}

38
FastMemos/Info.plist Normal file
View file

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2026 Paweł Orzech. All rights reserved.</string>
<key>NSMainNibFile</key>
<string></string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

View file

@ -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
}
}

View file

@ -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"
}
}
}

View file

@ -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)
}
}

View file

@ -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)")
}
}
}

View file

@ -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<ShortcutService>.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] ?? "?"
}
}

View file

@ -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)"
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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"
}
}

View file

@ -0,0 +1,257 @@
import SwiftUI
import AppKit
/// Floating panel for quick note capture
class NotePanel: NSPanel {
private var hostingView: NSHostingView<NoteWindowView>?
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
}
}

View file

@ -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)
}
}

74
README.md Normal file
View file

@ -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