Private
Public Access
1
0

11 Commits

Author SHA1 Message Date
8304b68a64 first attempt at trying to keep track of locally send id 2025-09-12 12:04:31 -07:00
6261351598 osx: wiring for opening a new window, but not connected to gesture yet
when I add `.tapGesture(count: 2)` to list items, this seems to block
single clicks because SwiftUI sucks. Need to find a better way to invoke
this.
2025-09-11 15:33:56 -07:00
955ff95520 osx: name app "Kordophone" instead of kordophone2 2025-09-11 15:33:31 -07:00
754ad3282d Merge branch 'wip/attachment_mime'
* wip/attachment_mime:
  core: attachment store: limit concurrent downloads
  core: attachment mime: prefer jpg instead of jfif
  wip: attachment MIME
2025-09-10 14:41:36 -07:00
f901077067 osx: some minor fixes 2025-09-10 14:41:24 -07:00
74d1a7f54b osx: try badging icon for unread 2025-09-09 18:54:14 -07:00
4b497aaabc osx: linkify text, enable selection 2025-09-09 15:45:50 -07:00
6caf008a39 osx: update kordophoned binary 2025-09-09 13:40:43 -07:00
d20afef370 kpcli: updates: print error on error 2025-09-09 13:36:35 -07:00
357be5cdf4 core: HTTPClient: update socket should just automatically retry on subsqeuent auth success 2025-09-09 13:33:13 -07:00
4db28222a6 core: HTTPClient: event stream should just automatically retry after auth token 2025-09-09 13:30:53 -07:00
16 changed files with 383 additions and 101 deletions

View File

@@ -0,0 +1,3 @@
-- Drop the alias mapping table
DROP TABLE IF EXISTS `message_aliases`;

View File

@@ -0,0 +1,7 @@
-- Add table to map local (client) IDs to server message GUIDs
CREATE TABLE IF NOT EXISTS `message_aliases` (
`local_id` TEXT NOT NULL PRIMARY KEY,
`server_id` TEXT NOT NULL UNIQUE,
`conversation_id` TEXT NOT NULL
);

View File

@@ -307,8 +307,11 @@ impl<'a> Repository<'a> {
}
pub fn delete_all_messages(&mut self) -> Result<()> {
use crate::schema::messages::dsl::*;
diesel::delete(messages).execute(self.connection)?;
use crate::schema::messages::dsl as messages_dsl;
use crate::schema::message_aliases::dsl as aliases_dsl;
diesel::delete(messages_dsl::messages).execute(self.connection)?;
diesel::delete(aliases_dsl::message_aliases).execute(self.connection)?;
Ok(())
}
@@ -359,6 +362,57 @@ impl<'a> Repository<'a> {
)
}
/// Create or update an alias mapping between a local (client) message id and a server message id.
pub fn set_message_alias(
&mut self,
local_id_in: &str,
server_id_in: &str,
conversation_id_in: &str,
) -> Result<()> {
use crate::schema::message_aliases::dsl::*;
diesel::replace_into(message_aliases)
.values((
local_id.eq(local_id_in),
server_id.eq(server_id_in),
conversation_id.eq(conversation_id_in),
))
.execute(self.connection)?;
Ok(())
}
/// Returns the local id for a given server id, if any.
pub fn get_local_id_for(&mut self, server_id_in: &str) -> Result<Option<String>> {
use crate::schema::message_aliases::dsl::*;
let result = message_aliases
.filter(server_id.eq(server_id_in))
.select(local_id)
.first::<String>(self.connection)
.optional()?;
Ok(result)
}
/// Batch lookup: returns a map server_id -> local_id for the provided server ids.
pub fn get_local_ids_for(
&mut self,
server_ids_in: Vec<String>,
) -> Result<HashMap<String, String>> {
use crate::schema::message_aliases::dsl::*;
if server_ids_in.is_empty() {
return Ok(HashMap::new());
}
let rows: Vec<(String, String)> = message_aliases
.filter(server_id.eq_any(&server_ids_in))
.select((server_id, local_id))
.load::<(String, String)>(self.connection)?;
let mut map = HashMap::new();
for (sid, lid) in rows {
map.insert(sid, lid);
}
Ok(map)
}
/// Update the contact_id for an existing participant record.
pub fn update_participant_contact(
&mut self,

View File

@@ -44,6 +44,14 @@ diesel::table! {
}
}
diesel::table! {
message_aliases (local_id) {
local_id -> Text,
server_id -> Text,
conversation_id -> Text,
}
}
diesel::table! {
settings (key) {
key -> Text,
@@ -62,5 +70,6 @@ diesel::allow_tables_to_appear_in_same_query!(
conversation_participants,
messages,
conversation_messages,
message_aliases,
settings,
);

View File

@@ -397,6 +397,7 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let uri = self
.uri_for_endpoint(&endpoint, Some(self.websocket_scheme()))?;
loop {
log::debug!("Connecting to websocket: {:?}", uri);
let auth = self.auth_store.get_token().await;
@@ -425,10 +426,11 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
log::debug!("Websocket request: {:?}", request);
let mut should_retry = true; // retry once after authenticating.
match connect_async(request).await.map_err(Error::from) {
Ok((socket, response)) => {
log::debug!("Websocket connected: {:?}", response.status());
Ok(WebsocketEventSocket::new(socket))
break Ok(WebsocketEventSocket::new(socket))
}
Err(e) => match &e {
Error::ClientError(ce) => match ce.as_str() {
@@ -439,22 +441,27 @@ impl<K: AuthenticationStore + Send + Sync> APIInterface for HTTPAPIClient<K> {
let new_token = self.authenticate(credentials.clone()).await?;
self.auth_store.set_token(new_token.to_string()).await;
if should_retry {
// try again on the next attempt.
return Err(Error::Unauthorized);
continue;
} else {
break Err(e);
}
} else {
log::error!("Websocket unauthorized, no credentials provided");
return Err(Error::ClientError(
break Err(Error::ClientError(
"Unauthorized, no credentials provided".into(),
));
}
}
_ => Err(e),
_ => break Err(e),
},
_ => Err(e),
_ => break Err(e),
},
}
}
}
}
impl<K: AuthenticationStore + Send + Sync> HTTPAPIClient<K> {

View File

@@ -347,7 +347,16 @@ impl Daemon {
self.database
.lock()
.await
.with_repository(|r| r.insert_message(&conversation_id, message.into()))
.with_repository(|r| {
// 1) Insert the server message
r.insert_message(&conversation_id, message.clone().into())?;
// 2) Persist alias local -> server for stable UI ids
r.set_message_alias(
&outgoing_message.guid.to_string(),
&message.id,
&conversation_id,
)
})
.await
.unwrap();
@@ -448,18 +457,38 @@ impl Daemon {
.get(&conversation_id)
.unwrap_or(&empty_vec);
self.database
// Fetch DB messages and an alias map (server_id -> local_id) in one DB access.
let (db_messages, alias_map) = self
.database
.lock()
.await
.with_repository(|r| {
r.get_messages_for_conversation(&conversation_id)
.unwrap()
.into_iter()
.map(|m| m.into()) // Convert db::Message to daemon::Message
.chain(outgoing_messages.into_iter().map(|m| m.into()))
.collect()
let msgs = r.get_messages_for_conversation(&conversation_id).unwrap();
let ids: Vec<String> = msgs.iter().map(|m| m.id.clone()).collect();
let map = r.get_local_ids_for(ids).unwrap_or_default();
(msgs, map)
})
.await
.await;
// Convert DB messages to daemon model, substituting local_id when an alias exists.
let mut result: Vec<Message> = Vec::with_capacity(
db_messages.len() + outgoing_messages.len(),
);
for m in db_messages.into_iter() {
let server_id = m.id.clone();
let mut dm: Message = m.into();
if let Some(local_id) = alias_map.get(&server_id) {
dm.id = local_id.clone();
}
result.push(dm);
}
// Append pending outgoing messages (these already use local_id)
for om in outgoing_messages.iter() {
result.push(om.into());
}
result
}
async fn enqueue_outgoing_message(

View File

@@ -143,7 +143,10 @@ impl ClientCli {
println!("Listening for raw updates...");
let mut stream = socket.raw_updates().await;
while let Some(Ok(update)) = stream.next().await {
loop {
match stream.next().await.unwrap() {
Ok(update) => {
match update {
SocketUpdate::Update(updates) => {
for update in updates {
@@ -154,6 +157,13 @@ impl ClientCli {
println!("Pong");
}
}
},
Err(e) => {
println!("Update error: {:?}", e);
break;
}
}
}
Ok(())

View File

@@ -32,29 +32,29 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
CD41F5972E5B8E7300E0027B /* kordophone2.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = kordophone2.app; sourceTree = BUILT_PRODUCTS_DIR; };
CD41F5972E5B8E7300E0027B /* Kordophone.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Kordophone.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */ = {
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Daemon/kordophoned,
Daemon/net.buzzert.kordophonecd.plist,
);
target = CD41F5962E5B8E7300E0027B /* kordophone2 */;
target = CD41F5962E5B8E7300E0027B /* Kordophone */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet section */
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = {
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
buildPhase = CD41F5D92E6284FD00E0027B /* CopyFiles */;
membershipExceptions = (
Daemon/net.buzzert.kordophonecd.plist,
);
};
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */ = {
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */ = {
isa = PBXFileSystemSynchronizedGroupBuildPhaseMembershipExceptionSet;
attributesByRelativePath = {
Daemon/kordophoned = (CodeSignOnCopy, );
@@ -70,9 +70,9 @@
CD41F5992E5B8E7300E0027B /* kordophone2 */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "kordophone2" target */,
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "kordophone2" target */,
CD41F5DA2E62850100E0027B /* Exceptions for "kordophone2" folder in "Kordophone" target */,
CD41F5DC2E62853800E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
CD41F5E12E62860700E0027B /* Exceptions for "kordophone2" folder in "Copy Files" phase from "Kordophone" target */,
);
path = kordophone2;
sourceTree = "<group>";
@@ -102,7 +102,7 @@
CD41F5982E5B8E7300E0027B /* Products */ = {
isa = PBXGroup;
children = (
CD41F5972E5B8E7300E0027B /* kordophone2.app */,
CD41F5972E5B8E7300E0027B /* Kordophone.app */,
);
name = Products;
sourceTree = "<group>";
@@ -110,9 +110,9 @@
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
CD41F5962E5B8E7300E0027B /* kordophone2 */ = {
CD41F5962E5B8E7300E0027B /* Kordophone */ = {
isa = PBXNativeTarget;
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */;
buildConfigurationList = CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */;
buildPhases = (
CD41F5932E5B8E7300E0027B /* Sources */,
CD41F5942E5B8E7300E0027B /* Frameworks */,
@@ -127,12 +127,12 @@
fileSystemSynchronizedGroups = (
CD41F5992E5B8E7300E0027B /* kordophone2 */,
);
name = kordophone2;
name = Kordophone;
packageProductDependencies = (
CD41F5D22E62431D00E0027B /* KeychainAccess */,
);
productName = kordophone2;
productReference = CD41F5972E5B8E7300E0027B /* kordophone2.app */;
productReference = CD41F5972E5B8E7300E0027B /* Kordophone.app */;
productType = "com.apple.product-type.application";
};
/* End PBXNativeTarget section */
@@ -167,7 +167,7 @@
projectDirPath = "";
projectRoot = "";
targets = (
CD41F5962E5B8E7300E0027B /* kordophone2 */,
CD41F5962E5B8E7300E0027B /* Kordophone */,
);
};
/* End PBXProject section */
@@ -322,7 +322,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD;
DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -349,7 +349,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = DQQH5H6GBD;
DEVELOPMENT_TEAM = 3SJALV9BQ7;
ENABLE_HARDENED_RUNTIME = NO;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -379,7 +379,7 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "kordophone2" */ = {
CD41F5A32E5B8E7400E0027B /* Build configuration list for PBXNativeTarget "Kordophone" */ = {
isa = XCConfigurationList;
buildConfigurations = (
CD41F5A42E5B8E7400E0027B /* Debug */,

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CD41F5962E5B8E7300E0027B"
BuildableName = "Kordophone.app"
BlueprintName = "Kordophone"
ReferencedContainer = "container:kordophone2.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@@ -14,6 +14,13 @@ struct KordophoneApp: App
WindowGroup {
SplitView()
}
.commands {
TextEditingCommands()
}
WindowGroup(id: .transcriptWindow, for: Display.Conversation.self) { selectedConversation in
TranscriptWindowView(conversation: selectedConversation)
}
Settings {
PreferencesView()
@@ -25,3 +32,42 @@ struct KordophoneApp: App
print("Error: \(e.localizedDescription)")
}
}
struct TranscriptWindowView: View
{
@State private var transcriptViewModel = TranscriptView.ViewModel()
@State private var entryViewModel = MessageEntryView.ViewModel()
private let displayedConversation: Binding<Display.Conversation?>
public init(conversation: Binding<Display.Conversation?>) {
self.displayedConversation = conversation
transcriptViewModel.displayedConversation = conversation.wrappedValue
observeDisplayedConversationChanges()
}
private func observeDisplayedConversationChanges() {
withObservationTracking {
_ = displayedConversation.wrappedValue
} onChange: {
Task { @MainActor in
guard let displayedConversation = self.displayedConversation.wrappedValue else { return }
transcriptViewModel.displayedConversation = displayedConversation
observeDisplayedConversationChanges()
}
}
}
var body: some View {
VStack {
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.navigationTitle(displayedConversation.wrappedValue?.displayName ?? "Kordophone")
.selectedConversation(displayedConversation.wrappedValue)
}
}
}
extension String
{
static let transcriptWindow = "TranscriptWindow"
}

View File

@@ -11,9 +11,10 @@ struct ConversationListView: View
{
@Binding var model: ViewModel
@Environment(\.xpcClient) private var xpcClient
@Environment(\.openWindow) private var openWindow
var body: some View {
List($model.conversations, selection: $model.selectedConversations) { conv in
List($model.conversations, selection: $model.selectedConversation) { conv in
let isUnread = conv.wrappedValue.unreadCount > 0
HStack(spacing: 0.0) {
@@ -64,14 +65,14 @@ struct ConversationListView: View
class ViewModel
{
var conversations: [Display.Conversation]
var selectedConversations: Set<Display.Conversation.ID>
var selectedConversation: Display.Conversation.ID?
private var needsReload: Bool = true
private let client = XPCClient()
public init(conversations: [Display.Conversation] = []) {
self.conversations = conversations
self.selectedConversations = Set()
self.selectedConversation = nil
setNeedsReload()
}
@@ -101,6 +102,11 @@ struct ConversationListView: View
.map { Display.Conversation(from: $0) }
self.conversations = clientConversations
let unreadConversations = clientConversations.filter(\.isUnread)
await MainActor.run {
NSApplication.shared.dockTile.badgeLabel = unreadConversations.isEmpty ? nil : "\(unreadConversations.count)"
}
} catch {
print("Error reloading conversations: \(error)")
}

Binary file not shown.

View File

@@ -36,6 +36,7 @@ struct MessageEntryView: View
.font(.body)
.scrollDisabled(true)
.disabled(selectedConversation == nil)
.id("messageEntry")
}
.padding(8.0)
.background {

View File

@@ -10,7 +10,7 @@ import XPC
enum Display
{
struct Conversation: Identifiable, Hashable
struct Conversation: Identifiable, Hashable, Codable
{
let id: String
let name: String?
@@ -27,6 +27,10 @@ enum Display
participants.count > 1
}
var isUnread: Bool {
unreadCount > 0
}
init(from c: Serialized.Conversation) {
self.id = c.guid
self.name = c.displayName

View File

@@ -15,7 +15,7 @@ struct SplitView: View
private let xpcClient = XPCClient()
private var selectedConversation: Display.Conversation? {
guard let id = conversationListModel.selectedConversations.first else { return nil }
guard let id = conversationListModel.selectedConversation else { return nil }
return conversationListModel.conversations.first { $0.id == id }
}
@@ -28,10 +28,10 @@ struct SplitView: View
ConversationView(transcriptModel: $transcriptViewModel, entryModel: $entryViewModel)
.xpcClient(xpcClient)
.selectedConversation(selectedConversation)
.navigationTitle("Kordophone")
.navigationSubtitle(selectedConversation?.displayName ?? "")
.onChange(of: conversationListModel.selectedConversations) { oldValue, newValue in
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue.first }
.navigationTitle(selectedConversation?.displayName ?? "Kordophone")
.navigationSubtitle(selectedConversation?.participants.joined(separator: ", ") ?? "")
.onChange(of: conversationListModel.selectedConversation) { oldValue, newValue in
transcriptViewModel.displayedConversation = conversationListModel.conversations.first { $0.id == newValue }
}
}
}

View File

@@ -67,7 +67,7 @@ struct TextBubbleItemView: View
BubbleView(sender: sender, date: date) {
HStack {
Text(text)
Text(text.linkifiedAttributedString())
.foregroundStyle(textColor)
.multilineTextAlignment(.leading)
}
@@ -75,6 +75,7 @@ struct TextBubbleItemView: View
.padding(.horizontal, 16.0)
.padding(.vertical, 10.0)
.background(bubbleColor)
.textSelection(.enabled)
}
}
}
@@ -219,14 +220,16 @@ struct SenderAttributionView: View
}
}
fileprivate extension CGFloat {
fileprivate extension CGFloat
{
static let dominantCornerRadius = 16.0
static let minorCornerRadius = 4.0
static let minimumBubbleHorizontalPadding = 80.0
static let imageMaxWidth = 380.0
}
fileprivate extension CGSize {
fileprivate extension CGSize
{
var aspectRatio: CGFloat { width / height }
}
@@ -239,3 +242,28 @@ fileprivate func preferredBubbleWidth(forAttachmentSize attachmentSize: CGSize?,
return 200.0 // fallback
}
}
fileprivate extension String
{
func linkifiedAttributedString() -> AttributedString {
var attributed = AttributedString(self)
guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else {
return attributed
}
let nsText = self as NSString
let fullRange = NSRange(location: 0, length: nsText.length)
detector.enumerateMatches(in: self, options: [], range: fullRange) { result, _, _ in
guard let result, let url = result.url,
let swiftRange = Range(result.range, in: self),
let start = AttributedString.Index(swiftRange.lowerBound, within: attributed),
let end = AttributedString.Index(swiftRange.upperBound, within: attributed) else { return }
attributed[start..<end].link = url
attributed[start..<end].foregroundColor = NSColor.textColor
attributed[start..<end].underlineStyle = .single
}
return attributed
}
}