mirror of
https://github.com/pawelorzech/FastMemos.git
synced 2026-01-29 19:54:29 +00:00
feat: Implement initial application structure, Memos API integration, and user interface for memo management.
This commit is contained in:
parent
61aa182e38
commit
fe3872165a
20 changed files with 1994 additions and 51 deletions
87
.gitignore
vendored
87
.gitignore
vendored
|
|
@ -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
BIN
AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
407
FastMemos.xcodeproj/project.pbxproj
Normal file
407
FastMemos.xcodeproj/project.pbxproj
Normal 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 */;
|
||||
}
|
||||
38
FastMemos/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
38
FastMemos/Assets.xcassets/AccentColor.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
58
FastMemos/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
58
FastMemos/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
6
FastMemos/Assets.xcassets/Contents.json
Normal file
6
FastMemos/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
10
FastMemos/FastMemos.entitlements
Normal file
10
FastMemos/FastMemos.entitlements
Normal 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>
|
||||
72
FastMemos/FastMemosApp.swift
Normal file
72
FastMemos/FastMemosApp.swift
Normal 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
38
FastMemos/Info.plist
Normal 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>
|
||||
57
FastMemos/Models/Memo.swift
Normal file
57
FastMemos/Models/Memo.swift
Normal 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
|
||||
}
|
||||
}
|
||||
32
FastMemos/Models/MemoVisibility.swift
Normal file
32
FastMemos/Models/MemoVisibility.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
85
FastMemos/Services/KeychainService.swift
Normal file
85
FastMemos/Services/KeychainService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
105
FastMemos/Services/MemosAPIService.swift
Normal file
105
FastMemos/Services/MemosAPIService.swift
Normal 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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
124
FastMemos/Services/ShortcutService.swift
Normal file
124
FastMemos/Services/ShortcutService.swift
Normal 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] ?? "?"
|
||||
}
|
||||
}
|
||||
141
FastMemos/ViewModels/AppState.swift
Normal file
141
FastMemos/ViewModels/AppState.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
FastMemos/Views/LoginView.swift
Normal file
131
FastMemos/Views/LoginView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
179
FastMemos/Views/MenuBarView.swift
Normal file
179
FastMemos/Views/MenuBarView.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
257
FastMemos/Views/NoteWindowView.swift
Normal file
257
FastMemos/Views/NoteWindowView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
144
FastMemos/Views/SettingsView.swift
Normal file
144
FastMemos/Views/SettingsView.swift
Normal 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
74
README.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
Loading…
Reference in a new issue