diff --git a/Frameworks/Resources/InfoPlist.xcstrings b/Frameworks/Resources/InfoPlist.xcstrings index 1327892e..bb442075 100644 --- a/Frameworks/Resources/InfoPlist.xcstrings +++ b/Frameworks/Resources/InfoPlist.xcstrings @@ -37,6 +37,23 @@ } } }, + "NSFaceIDUsageDescription": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Authenticate to use your SSH keys." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "通过身份验证以使用SSH密钥。" + } + } + } + }, "NSMicrophoneUsageDescription": { "comment": "Privacy - Microphone Usage Description", "extractionState": "extracted_with_value", diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 543a6896..acc486cd 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -323,6 +323,17 @@ } } }, + "%lld connected · %lld workspaces" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld个已连接,%2$lld个工作区" + } + } + } + }, "%lld exported, %lld failed" : { "localizations" : { "en" : { @@ -845,6 +856,17 @@ } } }, + "API Format" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "API格式" + } + } + } + }, "API Key" : { "localizations" : { "zh-Hans" : { @@ -1059,6 +1081,17 @@ } } }, + "Add a host to connect with SSH or SFTP." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加主机以通过SSH或SFTP连接。" + } + } + } + }, "Add a key first. This endpoint requires one to list models." : { "localizations" : { "zh-Hans" : { @@ -1199,6 +1232,17 @@ } } }, + "All Workspaces" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有工作区" + } + } + } + }, "All selected" : { "localizations" : { "zh-Hans" : { @@ -1717,6 +1761,28 @@ } } }, + "Bind Address" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监听地址" + } + } + } + }, + "Bind Port" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "监听端口" + } + } + } + }, "Blue" : { "localizations" : { "zh-Hans" : { @@ -2085,6 +2151,39 @@ } } }, + "Choose File" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择文件" + } + } + } + }, + "Choose Referenced File" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择引用的文件" + } + } + } + }, + "Choose SSH Files" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择SSH文件" + } + } + } + }, "Choose a SKILL.md file, or a folder that contains one." : { "comment" : "Title of a dialog box that appears when the user wants to import a skill.", "isCommentAutoGenerated" : true, @@ -2177,6 +2276,17 @@ } } }, + "Clear Finished" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除已结束任务" + } + } + } + }, "Clear History" : { "localizations" : { "zh-Hans" : { @@ -2573,6 +2683,17 @@ } } }, + "Configure a model and its API key in Settings before sending a message." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "发送消息前,请在设置中配置模型及其API密钥。" + } + } + } + }, "Confirm Passphrase" : { "localizations" : { "zh-Hans" : { @@ -2745,6 +2866,17 @@ } } }, + "Connection Error" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接错误" + } + } + } + }, "Connection Failed" : { "localizations" : { "zh-Hans" : { @@ -2935,6 +3067,17 @@ } } }, + "Conversations" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "对话" + } + } + } + }, "Convert legacy Office files to DOCX, XLSX, or PPTX first." : { "localizations" : { "zh-Hans" : { @@ -3337,6 +3480,28 @@ } } }, + "Delete File?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除文件?" + } + } + } + }, + "Delete Forwarding Rule?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除转发规则?" + } + } + } + }, "Delete Group" : { "localizations" : { "zh-Hans" : { @@ -3367,6 +3532,50 @@ } } }, + "Delete Host?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除主机?" + } + } + } + }, + "Delete Identity?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除身份?" + } + } + } + }, + "Delete Key?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除密钥?" + } + } + } + }, + "Delete Known Host?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除已知主机?" + } + } + } + }, "Delete Package" : { "localizations" : { "zh-Hans" : { @@ -3741,6 +3950,28 @@ } } }, + "Discard Changes" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放弃更改" + } + } + } + }, + "Discard Changes?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "放弃更改?" + } + } + } + }, "Discarded" : { "localizations" : { "zh-Hans" : { @@ -3751,6 +3982,28 @@ } } }, + "Disconnect" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "断开连接" + } + } + } + }, + "Disconnected" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已断开连接" + } + } + } + }, "Disk Read" : { "localizations" : { "zh-Hans" : { @@ -5359,6 +5612,17 @@ } } }, + "Hide Keyboard" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收起键盘" + } + } + } + }, "Hide Logs" : { "localizations" : { "zh-Hans" : { @@ -5539,6 +5803,17 @@ } } }, + "Hosts using this identity will ask for credentials again." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用此身份的主机将再次询问凭据。" + } + } + } + }, "Hosts you connect to appear here." : { "localizations" : { "zh-Hans" : { @@ -5619,6 +5894,17 @@ } } }, + "Identities using this key will no longer have access to it." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用此密钥的身份将无法再访问该密钥。" + } + } + } + }, "Identity" : { "localizations" : { "zh-Hans" : { @@ -5735,6 +6021,17 @@ } } }, + "Import SSH Files" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入SSH文件" + } + } + } + }, "Import Skill…" : { "comment" : "A button that imports a skill.", "isCommentAutoGenerated" : true, @@ -5851,6 +6148,17 @@ } } }, + "Inference URL" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "推理URL" + } + } + } + }, "Initial Path" : { "localizations" : { "zh-Hans" : { @@ -6051,6 +6359,17 @@ } } }, + "Key Storage" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "密钥存储位置" + } + } + } + }, "Key Type" : { "localizations" : { "zh-Hans" : { @@ -6363,6 +6682,17 @@ } } }, + "Live Activities" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "实时活动" + } + } + } + }, "Load Average" : { "localizations" : { "zh-Hans" : { @@ -6543,6 +6873,17 @@ } } }, + "Message" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "消息" + } + } + } + }, "Metrics" : { "localizations" : { "zh-Hans" : { @@ -6639,6 +6980,17 @@ } } }, + "Model ID" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "模型ID" + } + } + } + }, "Model Provider" : { "localizations" : { "zh-Hans" : { @@ -6905,6 +7257,17 @@ } } }, + "New Conversation" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建对话" + } + } + } + }, "New Folder" : { "localizations" : { "zh-Hans" : { @@ -7599,6 +7962,17 @@ } } }, + "Notes" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "备注" + } + } + } + }, "OK" : { "localizations" : { "zh-Hans" : { @@ -7661,6 +8035,17 @@ } } }, + "One Live Activity summarizes all workspaces. Tap it to open the workspace list. Live Activities do not keep SSH running in the background." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一条实时活动汇总所有工作区。轻点即可打开工作区列表。实时活动不会让SSH在后台持续运行。" + } + } + } + }, "One Workspace" : { "localizations" : { "zh-Hans" : { @@ -7837,6 +8222,17 @@ } } }, + "Open a host from Vault to start a workspace." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "从保险库打开主机以创建工作区。" + } + } + } + }, "Open a page, chat, workspace, host, or command" : { "localizations" : { "zh-Hans" : { @@ -7857,6 +8253,17 @@ } } }, + "Open connections and references to this host will be removed." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将关闭此主机的连接并移除相关引用。" + } + } + } + }, "Open current directory with SFTP — right-click for more options" : { "comment" : "A contextual help for the open button in the status bar.", "isCommentAutoGenerated" : true, @@ -7889,6 +8296,17 @@ } } }, + "Open to Check Connections" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开以检查连接" + } + } + } + }, "Open with SFTP" : { "localizations" : { "zh-Hans" : { @@ -7951,6 +8369,17 @@ } } }, + "Operation Failed" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作失败" + } + } + } + }, "Optional" : { "localizations" : { "zh-Hans" : { @@ -7971,6 +8400,17 @@ } } }, + "Others" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他用户" + } + } + } + }, "Output shortened for display" : { "localizations" : { "zh-Hans" : { @@ -8001,6 +8441,17 @@ } } }, + "Owner" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有者" + } + } + } + }, "Ownership" : { "localizations" : { "zh-Hans" : { @@ -8057,6 +8508,17 @@ } } }, + "Parent Directory" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "上级目录" + } + } + } + }, "Parity" : { "localizations" : { "zh-Hans" : { @@ -8598,6 +9060,17 @@ } } }, + "Pro Active" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pro已激活" + } + } + } + }, "Process" : { "localizations" : { "zh-Hans" : { @@ -9208,6 +9681,17 @@ } } }, + "Remote MCP" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "远程MCP" + } + } + } + }, "Remote Path" : { "localizations" : { "zh-Hans" : { @@ -9238,6 +9722,17 @@ } } }, + "Remove" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移除" + } + } + } + }, "Remove %@" : { "localizations" : { "zh-Hans" : { @@ -9524,6 +10019,17 @@ } } }, + "Restore Purchases" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复购买" + } + } + } + }, "Restore Workspaces from Last Session" : { "localizations" : { "zh-Hans" : { @@ -9894,6 +10400,17 @@ } } }, + "SOCKS5 on This Device → SSH Host → Requested Target" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备的SOCKS5→SSH主机→请求的目标" + } + } + } + }, "SOCKS5 on This Mac → SSH Host → Requested Target" : { "localizations" : { "zh-Hans" : { @@ -9926,6 +10443,17 @@ } } }, + "SSH Certificate" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH证书" + } + } + } + }, "SSH Command" : { "localizations" : { "zh-Hans" : { @@ -9956,6 +10484,17 @@ } } }, + "SSH Host → This Device → Target" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH主机→此设备→目标" + } + } + } + }, "SSH Host → This Mac → Target" : { "localizations" : { "zh-Hans" : { @@ -10066,6 +10605,17 @@ } } }, + "Save or Share" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "保存或分享" + } + } + } + }, "Save required" : { "localizations" : { "zh-Hans" : { @@ -10210,6 +10760,17 @@ } } }, + "Secure Enclave is not available on this device." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备不支持安全隔区。" + } + } + } + }, "Secure Enclave keys cannot be moved to another storage location." : { "localizations" : { "zh-Hans" : { @@ -10664,6 +11225,17 @@ } } }, + "Show Workspace Live Activity" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示工作区实时活动" + } + } + } + }, "Show details" : { "localizations" : { "zh-Hans" : { @@ -10904,6 +11476,17 @@ } } }, + "Snippet" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "代码片段" + } + } + } + }, "Snippet Name" : { "comment" : "Label for the text field used to name a new snippet.", "isCommentAutoGenerated" : true, @@ -10976,6 +11559,17 @@ } } }, + "Special Permissions" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "特殊权限" + } + } + } + }, "Speed" : { "localizations" : { "zh-Hans" : { @@ -11074,6 +11668,17 @@ } } }, + "Start Free Trial" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开始免费试用" + } + } + } + }, "Start a Conversation" : { "localizations" : { "zh-Hans" : { @@ -11310,6 +11915,17 @@ } } }, + "Storage" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "存储位置" + } + } + } + }, "Store in" : { "localizations" : { "zh-Hans" : { @@ -11482,6 +12098,28 @@ } } }, + "Target Host" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标主机" + } + } + } + }, + "Target Port" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标端口" + } + } + } + }, "Targets" : { "localizations" : { "zh-Hans" : { @@ -11578,6 +12216,17 @@ } } }, + "Termind" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind" + } + } + } + }, "Termind Pro" : { "comment" : "The name of the app.", "isCommentAutoGenerated" : true, @@ -11602,6 +12251,17 @@ } } }, + "Termind Workspaces" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Termind工作区" + } + } + } + }, "Termind connects directly from this Mac over HTTP. Tools ask for approval before running by default." : { "localizations" : { "zh-Hans" : { @@ -12520,6 +13180,28 @@ } } }, + "This Device" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备" + } + } + } + }, + "This Device → SSH Host → Target" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备→SSH主机→目标" + } + } + } + }, "This Mac" : { "localizations" : { "zh-Hans" : { @@ -12784,6 +13466,17 @@ } } }, + "This workspace was closed or its host was removed." : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此工作区已关闭或其主机已移除。" + } + } + } + }, "Title" : { "localizations" : { "zh-Hans" : { @@ -13058,6 +13751,28 @@ } } }, + "Trust" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "信任" + } + } + } + }, + "Trust Host Key?" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "信任主机密钥?" + } + } + } + }, "Trust and Continue" : { "localizations" : { "zh-Hans" : { @@ -13152,6 +13867,17 @@ } } }, + "Unable to Delete" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法删除" + } + } + } + }, "Unable to Load Hosts" : { "localizations" : { "zh-Hans" : { @@ -13172,6 +13898,28 @@ } } }, + "Unable to Read File" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法读取文件" + } + } + } + }, + "Unable to Save" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法保存" + } + } + } + }, "Unable to create an SSH forwarding session." : { "localizations" : { "zh-Hans" : { @@ -13292,6 +14040,28 @@ } } }, + "Unlimited Metrics" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "不限数量的监控" + } + } + } + }, + "Unlock" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁" + } + } + } + }, "Unlock Key" : { "localizations" : { "zh-Hans" : { @@ -13412,6 +14182,17 @@ } } }, + "Updated" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新时间" + } + } + } + }, "Updates or may update local Git references." : { "localizations" : { "zh-Hans" : { @@ -13422,6 +14203,17 @@ } } }, + "Upgrade to Pro" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "升级到Pro" + } + } + } + }, "Upload" : { "localizations" : { "zh-Hans" : { @@ -13744,6 +14536,17 @@ } } }, + "Waiting for approval" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待批准" + } + } + } + }, "Waiting for authentication" : { "localizations" : { "zh-Hans" : { @@ -13968,6 +14771,17 @@ } } }, + "Workspace Unavailable" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工作区不可用" + } + } + } + }, "Workspace usage" : { "localizations" : { "zh-Hans" : { @@ -14050,6 +14864,17 @@ } } }, + "You" : { + "extractionState" : "manual", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你" + } + } + } + }, "You stopped after %@" : { "localizations" : { "zh-Hans" : { diff --git a/Frameworks/TermindApplication/AIConfigurationPersisting.swift b/Frameworks/TermindApplication/AIConfigurationPersisting.swift new file mode 100644 index 00000000..b66d0ce2 --- /dev/null +++ b/Frameworks/TermindApplication/AIConfigurationPersisting.swift @@ -0,0 +1,35 @@ +import AgentKit +import Foundation +import TermindDomain + +public nonisolated struct AIConfigurationSnapshot: Sendable { + public var providers: [ModelProvider] + /// What was typed and picked per provider — see `AIConfigurationModel.endpoints`. + public var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] + public var roles: [AIRole: AIModelRef] + public var lastSelectedModel: AIModelRef? + public var mcpServers: [MCPServer] = [] + public init( + providers: [ModelProvider], endpoints: [ModelProvider.ID: ProviderEndpoint] = [:], roles: [AIRole: AIModelRef], + lastSelectedModel: AIModelRef? = nil, mcpServers: [MCPServer] = [] + ) { + self.providers = providers + self.endpoints = endpoints + self.roles = roles + self.lastSelectedModel = lastSelectedModel + self.mcpServers = mcpServers + } + +} + +@MainActor +public protocol AIConfigurationPersisting: AnyObject { + func load() throws -> AIConfigurationSnapshot + func save(_ snapshot: AIConfigurationSnapshot) throws +} + +@MainActor +public protocol AIWebSearchPersisting: AnyObject { + func loadWebSearch() throws -> AIWebSearchPreferences + func saveWebSearch(_ value: AIWebSearchPreferences) throws +} diff --git a/Frameworks/TermindApplication/AISecretStoring.swift b/Frameworks/TermindApplication/AISecretStoring.swift new file mode 100644 index 00000000..81454519 --- /dev/null +++ b/Frameworks/TermindApplication/AISecretStoring.swift @@ -0,0 +1,7 @@ +import Foundation + +@MainActor +public protocol AISecretStoring: AnyObject { + func string(for reference: String) throws -> String? + func setString(_ value: String?, for reference: String) throws +} diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift new file mode 100644 index 00000000..61b7e77f --- /dev/null +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -0,0 +1,44 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// The host, trust, and credential capabilities required to establish a live +/// SSH-backed session. Session features depend on this port instead of gaining +/// the complete Vault mutation surface from `HostLibraryModel`. +@MainActor +public protocol RemoteSessionHostStore: AnyObject, Sendable { + func host(_ id: Host.ID) -> Host? + func identity(_ id: Identity.ID) -> Identity? + func identity(for host: Host) -> Identity? + func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? + func knownHosts(for host: Host) -> [KnownHostEntry] + func route(for host: Host) -> [Host] + func expectedHostKeys( + for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> Set + func prepareSSHConnection( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission] + ) throws -> SSHConnectionPreparation + func sshConnectionConfiguration( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> SSHConnectionConfiguration + func sshJumpConfigurations( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> [SSHRouteHopConfiguration] + func transportProxy(for host: Host) throws -> SSHTransportProxy? + func sshCredentials(for identity: Identity) -> SSHCredentialResolution + func passwordCredential(for identity: Identity) throws -> String? + func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? + func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID) + func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) + func trust(_ entry: KnownHostEntry) + func markKnownHostMismatched(hostname: String, port: Int) + @discardableResult func upsert(_ identity: Identity) -> Bool + func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws + func upsert(_ host: Host) +} + +extension HostLibraryModel: RemoteSessionHostStore {} diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift new file mode 100644 index 00000000..8f2e4596 --- /dev/null +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -0,0 +1,33 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Sendable { + public nonisolated enum Mode: Hashable, Sendable { + case localIdentity + case serverChallenge + } + + public let id = UUID() + public var host: Host + public var selectedIdentityID: Identity.ID? = nil + public var username: String + public var selectedKeyID: SSHKeyRef.ID? + public var failureMessage: String? + public var requiresPassphrase = false + public var mode: Mode = .localIdentity + public init( + host: Host, selectedIdentityID: Identity.ID? = nil, username: String, selectedKeyID: SSHKeyRef.ID? = nil, + failureMessage: String? = nil, requiresPassphrase: Bool = false, mode: Mode = .localIdentity + ) { + self.host = host + self.selectedIdentityID = selectedIdentityID + self.username = username + self.selectedKeyID = selectedKeyID + self.failureMessage = failureMessage + self.requiresPassphrase = requiresPassphrase + self.mode = mode + } + +} diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift new file mode 100644 index 00000000..ab16d5fc --- /dev/null +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -0,0 +1,140 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// A value snapshot taken on MainActor. Resolving Keychain references can block and +/// therefore happens on a detached worker, before any SSH transport is opened. +public nonisolated struct SSHConnectionPreparation: Sendable { + public struct Node: Sendable { + public var host: Host + public var identity: Identity? + public var key: SSHKeyRef? + public var submission: SSHAuthenticationSubmission? + public var trustedKeys: Set + public var proxyIdentity: Identity? + public init( + host: Host, identity: Identity?, key: SSHKeyRef?, submission: SSHAuthenticationSubmission?, + trustedKeys: Set, proxyIdentity: Identity? + ) { + self.host = host + self.identity = identity + self.key = key + self.submission = submission + self.trustedKeys = trustedKeys + self.proxyIdentity = proxyIdentity + } + + } + public var nodes: [Node] + public var secrets: any SecretStoring + + public func resolve(discoveryOnly: Bool = false) async throws -> SSHConnectionConfiguration { + let task = Task.detached { try resolveSynchronously(discoveryOnly: discoveryOnly) } + return try await withTaskCancellationHandler { + let result = try await task.value + try Task.checkCancellation() + return result + } onCancel: { + task.cancel() + } + } + + private func resolveSynchronously(discoveryOnly: Bool) throws -> SSHConnectionConfiguration { + guard let target = nodes.last else { + throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) + } + var jumps: [SSHRouteHopConfiguration] = [] + for (offset, node) in nodes.dropLast().enumerated() { + do { + try Task.checkCancellation() + let material = try credentials(node) + guard !node.trustedKeys.isEmpty else { throw TermindSSHError.untrustedHost } + jumps.append( + SSHRouteHopConfiguration( + id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port, + username: material.0, credentials: material.1, + expectedHostKeys: node.trustedKeys, proxy: try proxy(node) + )) + } catch { + if error is CancellationError { throw error } + let ssh = error as? TermindSSHError + throw TermindSSHError.routeHost( + hostID: node.host.id, authentication: ssh?.isLocalAuthenticationRequirement == true, + serverChallenge: false, hostKey: isUntrusted(ssh), + message: String( + localized: "Jump Host \(offset + 1) (\(node.host.name)): \(error.localizedDescription)") + ) + } + } + try Task.checkCancellation() + let material = discoveryOnly ? ("", [SSHCredentials]()) : try credentials(target) + if !discoveryOnly, target.trustedKeys.isEmpty { throw TermindSSHError.untrustedHost } + return SSHConnectionConfiguration( + hostID: target.host.id, host: target.host.host, port: target.host.port, + username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys, + proxy: try proxy(target), jumps: jumps + ) + } + + private func credentials(_ node: Node) throws -> (String, [SSHCredentials]) { + let username = (node.submission?.username ?? node.identity?.username ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + var result: [SSHCredentials] = [] + if let key = node.key { + if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false { + throw TermindSSHError.keyPassphraseRequired + } + if let credential = try? secrets.keyCredential(for: key.credentialRef, storage: key.storage) { + result.append( + credential.sshCredential( + publicKey: key.publicKey, certificate: key.certificate, + passphraseOverride: node.submission?.passphrase + )) + } + } + try Task.checkCancellation() + if let password = node.submission?.password { + result.append(.password(password)) + } else if node.submission == nil || node.submission?.identityID != nil, + let identity = node.identity, identity.usesPassword, + let password = try? secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) + { + result.append(.password(password)) + } + guard !result.isEmpty else { throw TermindSSHError.missingCredential } + return (username, result) + } + + private func proxy(_ node: Node) throws -> SSHTransportProxy? { + guard let proxy = node.host.proxy else { return nil } + guard proxy.isValid else { + throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) + } + var password: String? + if proxy.identityID != nil { + guard let identity = node.proxyIdentity, identity.usesPassword else { + throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable.")) + } + password = try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) + guard password != nil else { + throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) + } + } + return SSHTransportProxy( + kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, + username: proxy.username, password: password + ) + } + + private func isUntrusted(_ error: TermindSSHError?) -> Bool { + if case .untrustedHost = error { return true } + return false + } + public init(nodes: [Node], secrets: any SecretStoring) { + self.nodes = nodes + self.secrets = secrets + } + +} diff --git a/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift b/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift new file mode 100644 index 00000000..87b84e73 --- /dev/null +++ b/Frameworks/TermindApplication/Events/HostDeletionHandlers.swift @@ -0,0 +1,42 @@ +import Foundation +import TermindDomain + +@MainActor +public final class RepositoryHostDeletionHandler: OutboxEventHandler { + public nonisolated let handlerID: String + private let repository: any HostRepository + + public init(handlerID: String, repository: any HostRepository) { + self.handlerID = handlerID + self.repository = repository + } + + public func handle(type: String, payload: Data, eventID: UUID) async throws { + guard type == HostDeletionOutbox.eventType else { return } + let deletion = try JSONDecoder().decode(HostDeletionPayload.self, from: payload) + switch handlerID { + case HostDeletionOutbox.clearReferences: + try repository.clearReferencesForDeletedHost(deletion.hostID) + case HostDeletionOutbox.finalize: + try repository.finalizeHostDeletion(deletion.hostID) + default: + break + } + } +} + +@MainActor +public final class RuntimeHostDeletionHandler: OutboxEventHandler { + public nonisolated let handlerID: String + private let action: (HostDeletionPayload) -> Void + + public init(handlerID: String, action: @escaping (HostDeletionPayload) -> Void) { + self.handlerID = handlerID + self.action = action + } + + public func handle(type: String, payload: Data, eventID: UUID) async throws { + guard type == HostDeletionOutbox.eventType else { return } + action(try JSONDecoder().decode(HostDeletionPayload.self, from: payload)) + } +} diff --git a/Frameworks/TermindApplication/Events/OutboxRunner.swift b/Frameworks/TermindApplication/Events/OutboxRunner.swift new file mode 100644 index 00000000..30f48916 --- /dev/null +++ b/Frameworks/TermindApplication/Events/OutboxRunner.swift @@ -0,0 +1,85 @@ +import Foundation + +/// Executes durable jobs one at a time. Persisted status is the idempotency +/// ledger, and handlers receive the stable event id for their own deduplication. +@MainActor +public final class OutboxRunner { + private let store: any OutboxStore + private var handlers: [String: any OutboxEventHandler] + private var task: Task? + private var generation: UUID? + private let maximumAttempts: Int + + public init( + store: any OutboxStore, handlers: [any OutboxEventHandler] = [], + maximumAttempts: Int = 5 + ) { + self.store = store + self.handlers = Dictionary(uniqueKeysWithValues: handlers.map { ($0.handlerID, $0) }) + self.maximumAttempts = maximumAttempts + } + + public func register(_ handler: any OutboxEventHandler) { + handlers[handler.handlerID] = handler + } + + public func start() { + guard task == nil else { return } + do { try store.resetInterruptedJobs() } catch { return } + let id = UUID() + generation = id + task = Task { [weak self] in await self?.run(generation: id) } + } + + public func stop() { + task?.cancel() + task = nil + generation = nil + } + + private func run(generation id: UUID) async { + defer { + if generation == id { + task = nil + generation = nil + } + } + while !Task.isCancelled && generation == id { + do { + guard let job = try store.nextReady(at: Date()) else { + try await Task.sleep(for: .seconds(1)) + continue + } + guard let handler = handlers[job.handlerID] else { + try store.markFailed( + job.id, error: "No outbox handler is registered for \(job.handlerID).", + retryAt: nil + ) + continue + } + try store.markRunning(job.id) + do { + try await handler.handle( + type: job.eventType, payload: job.payload, eventID: job.eventID + ) + guard generation == id, !Task.isCancelled else { return } + try store.markSucceeded(job.id) + } catch { + guard generation == id, !Task.isCancelled else { return } + let attempt = job.attemptCount + 1 + let retryAt = + attempt >= maximumAttempts + ? nil + : Date().addingTimeInterval(pow(2, Double(attempt))) + try store.markFailed( + job.id, error: error.localizedDescription, retryAt: retryAt + ) + } + } catch is CancellationError { + return + } catch { + try? await Task.sleep(for: .seconds(1)) + } + } + } +} diff --git a/Frameworks/TermindApplication/Events/OutboxStore.swift b/Frameworks/TermindApplication/Events/OutboxStore.swift new file mode 100644 index 00000000..c8f38048 --- /dev/null +++ b/Frameworks/TermindApplication/Events/OutboxStore.swift @@ -0,0 +1,18 @@ +import Foundation +import TermindDomain + +@MainActor +public protocol OutboxStore: AnyObject { + func enqueue(_ jobs: [OutboxJob]) throws + func nextReady(at date: Date) throws -> OutboxJob? + func markRunning(_ id: OutboxJob.ID) throws + func markSucceeded(_ id: OutboxJob.ID) throws + func markFailed(_ id: OutboxJob.ID, error: String, retryAt: Date?) throws + func resetInterruptedJobs() throws + func retryFailedJob(_ id: OutboxJob.ID) throws +} + +public protocol OutboxEventHandler: AnyObject { + nonisolated var handlerID: String { get } + func handle(type: String, payload: Data, eventID: UUID) async throws +} diff --git a/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift new file mode 100644 index 00000000..95358ce9 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/AIConfigurationModel.swift @@ -0,0 +1,621 @@ +import AgentKit +import Foundation +import TermindDomain + +/// The single source of truth for model providers, their models, which model does +/// what, and the MCP servers. +/// +/// Its own store rather than more fields on `HostLibraryModel`: that one is about +/// machines you connect to, this one is about endpoints you spend a key at. +/// Provider and MCP configuration are projected from SwiftData in production +/// while previews can still construct an in-memory store. +@Observable +public final class AIConfigurationModel { + public private(set) var modelProviders: [ModelProvider] = [] + + /// What the user typed and picked for each provider's endpoint. + /// + /// Beside the providers rather than on them, because `ModelProvider` is + /// AgentKit's type and this app does not get to add a field to it. Neither + /// half survives a round trip through the composed result — a URL ending in + /// `/messages` looks the same whether it was typed or appended, and + /// `Automatic` looks the same as picking the format it resolved to — so + /// there is nothing to derive it from and it has to be kept. + /// + /// A missing entry is not a broken one: it reads as `Automatic` over the + /// provider.s stored `inferenceURL`, which resolves to the format already recorded + /// there. The editor shows the same endpoint either way. + private var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] + + public private(set) var mcpServers: [MCPServer] = [] + + /// Explicit role assignments. Absent means "work it out" — see `resolve`. + public private(set) var roles: [AIRole: AIModelRef] = [:] + + /// The last model picked in a conversation's model menu. + /// + /// This is what makes the app usable with no configuration at all: choosing + /// a model once, in the place you were already looking, quietly becomes the + /// default for every conversation that has not chosen. It sits below an + /// explicit Chat assignment and above first-available in `resolve`, so it + /// never overrides a decision the user made in Settings. + public private(set) var lastSelectedModel: AIModelRef? + + /// The approval posture new and otherwise-unset conversations follow. + /// Stored in local preferences rather than provider configuration so it + /// survives relaunches without becoming part of the synced SwiftData model. + public private(set) var lastSelectedPermissionMode: AgentPermissionMode + + /// Live MCP connection state, keyed by server. + /// + /// Here rather than on `MCPServer` for the same reason `HostLibraryModel.statuses` + /// is not on `Host`: it is session state, not user data, and it must never + /// be persisted or synced. + public private(set) var mcpStatuses: [MCPServer.ID: ConnectionStatus] = [:] + + /// Session cache for API keys and service-account JSON. Production reads + /// through to the Data Protection Keychain; previews keep their fake secret + /// in memory so demo data never creates a real Keychain item. + private var secrets: [String: String] = [:] + private let aiSecrets: (any AISecretStoring)? + private let webSearchPersistence: (any AIWebSearchPersisting)? + public private(set) var webSearch = AIWebSearchPreferences() + private let keychain: any SecretStoring + private let persistsSecrets: Bool + private let permissionDefaults: UserDefaults? + public private(set) var secretStorageError: String? + private var persistence: (any AIConfigurationPersisting)? + public private(set) var storageError: String? + + public init( + modelProviders: [ModelProvider] = [], + endpoints: [ModelProvider.ID: ProviderEndpoint] = [:], + mcpServers: [MCPServer] = [], + roles: [AIRole: AIModelRef] = [:], + lastSelectedModel: AIModelRef? = nil, + lastSelectedPermissionMode: AgentPermissionMode = .askForApproval, + persistence: (any AIConfigurationPersisting)? = nil, + secrets secretStore: any SecretStoring = InMemorySecretStore(), + persistsSecrets: Bool = true, + permissionDefaults: UserDefaults? = nil, + aiSecrets: (any AISecretStoring)? = nil, + webSearchPersistence: (any AIWebSearchPersisting)? = nil + ) { + self.aiSecrets = aiSecrets + self.webSearchPersistence = webSearchPersistence + self.modelProviders = modelProviders + self.endpoints = endpoints + self.mcpServers = mcpServers + self.roles = roles + self.lastSelectedModel = lastSelectedModel + self.lastSelectedPermissionMode = + permissionDefaults + .flatMap { $0.string(forKey: "assistant.permissionMode") } + .flatMap(AgentPermissionMode.init(rawValue:)) + ?? lastSelectedPermissionMode + self.persistence = persistence + self.keychain = secretStore + self.persistsSecrets = persistsSecrets + self.permissionDefaults = permissionDefaults + if let webSearchPersistence { + do { webSearch = try webSearchPersistence.loadWebSearch() } catch { + storageError = error.localizedDescription + } + } + guard let persistence else { return } + do { + let loaded = try persistence.load() + self.modelProviders = loaded.providers + self.endpoints = loaded.endpoints + self.roles = loaded.roles + self.lastSelectedModel = loaded.lastSelectedModel + self.mcpServers = loaded.mcpServers + } catch { + storageError = error.localizedDescription + } + } + + // MARK: - Reads + + public func modelProvider(_ id: ModelProvider.ID) -> ModelProvider? { + modelProviders.first { $0.id == id } + } + + /// What the editor shows for a provider's endpoint. Falls back to reading + /// the composed URL as `Automatic` — see `endpoints`. + public func endpoint(for id: ModelProvider.ID) -> ProviderEndpoint { + if let stored = endpoints[id] { return stored } + return ProviderEndpoint(url: modelProvider(id)?.inferenceURL ?? "") + } + + /// Providers whose models may be offered anywhere. A provider with no + /// credential is included: it may have hand-added models, and hiding it + /// would make "why is my model missing" unanswerable from the picker. + public var enabledModelProviders: [ModelProvider] { + modelProviders.filter(\.isEnabled) + } + + /// Resolves a ref to the pair it names, or `nil` if either half has since + /// been deleted, renamed, or disabled. + /// + /// Every step of `resolve` runs through here, which is what stops a stale + /// assignment from silently pointing at nothing. + /// + /// A missing URL is deliberately *not* one of the disqualifying conditions, + /// for the same reason a missing credential is not: the model is sitting + /// right there in Settings, and a picker that quietly skips it makes "why is + /// my model missing" unanswerable. Sending to a blank URL now fails with a + /// bad-URL error naming the problem, which is the better of the two — there + /// is no default endpoint left for it to reach instead. + public func lookup(_ ref: AIModelRef) -> (provider: ModelProvider, model: AIModel)? { + guard + let provider = modelProvider(ref.modelProviderID), + provider.isEnabled, + let model = provider.model(ref.modelID) + else { return nil } + return (provider, model) + } + + /// The fallback of last resort: the first chat model of the first enabled + /// provider, in the order the lists are shown. + public var firstAvailableModel: AIModelRef? { + for provider in enabledModelProviders { + if let model = provider.chatModels.first { + return AIModelRef(modelProviderID: provider.id, modelID: model.id) + } + } + return nil + } + + /// Which model answers a given job. + /// + /// One chain, and no step of it is required: + /// + /// 1. an explicit assignment for this role + /// 2. for non-chat roles, whatever Chat resolves to + /// 3. the last model picked in a conversation's menu + /// 4. the first chat model of the first enabled provider + /// + /// Any step whose ref no longer resolves is skipped rather than returning + /// nothing, so deleting a provider degrades the app instead of breaking it. + /// The practical effect: add one provider, press Fetch, and the assistant + /// works — the role pickers are for tuning, not for setup. + public func resolve(_ role: AIRole) -> (provider: ModelProvider, model: AIModel)? { + if let ref = roles[role], let hit = lookup(ref) { return hit } + if role != .chat, let ref = roles[.chat], let hit = lookup(ref) { return hit } + if let ref = lastSelectedModel, let hit = lookup(ref) { return hit } + if let ref = firstAvailableModel, let hit = lookup(ref) { return hit } + return nil + } + + /// The model a conversation will actually use. + /// + /// Its own choice if it made one, and whatever `.chat` resolves to if it did + /// not. A conversation is left unset rather than assigned a model on + /// creation, so this is the only place that difference has to be understood + /// — the Assistant sidebar's model menu, the composer's caption, and the status bar + /// all ask here instead of each reimplementing the fallback slightly + /// differently. + public func resolve(conversation model: AIModelRef?) -> (provider: ModelProvider, model: AIModel)? { + if let model, let hit = lookup(model) { return hit } + return resolve(.chat) + } + + /// An explicit conversation choice wins; otherwise the app-wide latest + /// choice is the default. The latter begins at the safest posture. + public func resolve(conversation permissionMode: AgentPermissionMode?) -> AgentPermissionMode { + permissionMode ?? lastSelectedPermissionMode + } + + /// Whether a role is showing an explicit choice or following the chain. + /// The picker needs this to know when to show its unset tag. + public func assignment(for role: AIRole) -> AIModelRef? { + guard let ref = roles[role], lookup(ref) != nil else { return nil } + return ref + } + + /// Which roles would lose their explicit model if a provider were deleted. + /// + /// The counterpart of `HostLibraryModel.identities(using:)`, and used for the same + /// reason: a delete button should say what it takes with it. + public func roles(using modelProviderID: ModelProvider.ID) -> [AIRole] { + AIRole.allCases.filter { roles[$0]?.modelProviderID == modelProviderID } + } + + public func mcpServer(_ id: MCPServer.ID) -> MCPServer? { + mcpServers.first { $0.id == id } + } + + public func mcpStatus(of id: MCPServer.ID) -> ConnectionStatus { + mcpStatuses[id] ?? .disconnected + } + + public func hasMCPNamespaceConflict(_ namespaceID: String, excluding id: MCPServer.ID) -> Bool { + !namespaceID.isEmpty + && mcpServers.contains { + $0.id != id && $0.effectiveNamespaceID == namespaceID + } + } + + /// Every tool the model may be offered: enabled tools on enabled servers. + public var availableTools: [(server: MCPServer, tool: MCPTool)] { + usableMCPServers.flatMap { server in + server.enabledTools.map { (server, $0) } + } + } + + public var conflictingMCPServerIDs: Set { + var names = Set() + var conflicts = Set() + for server in mcpServers.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { + if !server.effectiveNamespaceID.isEmpty, !names.insert(server.effectiveNamespaceID).inserted { + conflicts.insert(server.id) + } + } + return conflicts + } + + public var usableMCPServers: [MCPServer] { + let conflicts = conflictingMCPServerIDs + return mcpServers.filter { $0.isEnabled && !conflicts.contains($0.id) } + } + + public func setWebSearch(_ value: AIWebSearchPreferences) { + do { + try webSearchPersistence?.saveWebSearch(value) + webSearch = value + storageError = nil + } catch { storageError = error.localizedDescription } + } + + /// Applies an already committed replica. It never calls the local write path. + public func applyRemoteSnapshot(_ snapshot: AIConfigurationSnapshot, webSearch: AIWebSearchPreferences) { + let previousServers = Dictionary(uniqueKeysWithValues: mcpServers.map { ($0.id, $0) }) + modelProviders = snapshot.providers + endpoints = snapshot.endpoints + roles = snapshot.roles + lastSelectedModel = snapshot.lastSelectedModel + mcpServers = snapshot.mcpServers + mcpStatuses = mcpStatuses.filter { id, _ in + previousServers[id] == mcpServers.first { $0.id == id } + } + self.webSearch = webSearch + secrets.removeAll() + storageError = nil + } + + // MARK: - Secrets + + /// The secret behind a `credentialRef`, loaded from the Termind Data + /// Protection Keychain and cached only for this process. + public func secret(for ref: String) -> String? { + guard !ref.isEmpty else { return nil } + if let aiSecrets, persistsSecrets { + do { + let value = try aiSecrets.string(for: ref) + secretStorageError = nil + return value + } catch { + secretStorageError = error.localizedDescription + return nil + } + } + if let cached = secrets[ref] { return cached } + guard persistsSecrets else { return nil } + do { + let value = try keychain.string(for: ref, storage: .thisDevice) + if let value { secrets[ref] = value } + secretStorageError = nil + return value + } catch { + secretStorageError = error.localizedDescription + return nil + } + } + + /// Stores or clears a secret. `nil` forgets it, which is what emptying the + /// field in an editor has to mean. + public func setSecret(_ secret: String?, for ref: String) { + guard !ref.isEmpty else { return } + do { + if persistsSecrets { + if let aiSecrets { + try aiSecrets.setString(secret, for: ref) + } else { + try keychain.setString(secret, for: ref, storage: .thisDevice) + } + } + secrets[ref] = secret + secretStorageError = nil + } catch { + secretStorageError = error.localizedDescription + } + } + + /// Mints a reference for a new secret. The plaintext is handed over + /// separately so the two are never in one value — the trick + /// `NewIdentitySheet` already uses for passwords. + public static func makeCredentialRef(_ kind: String = "key") -> String { + "termind.ai.\(kind).\(UUID().uuidString)" + } + + // MARK: - Model provider writes + + public func upsert(_ provider: ModelProvider) { + mutatePersistedState { + if let index = modelProviders.firstIndex(where: { $0.id == provider.id }) { + modelProviders[index] = provider + } else { + modelProviders.append(provider) + } + } + } + + /// The only place `url` and `apiFormat` are ever written. + /// + /// Everything else edits a name, a key or a model list. Keeping the + /// derivation in one function is what lets the caption under the URL field + /// promise where requests land: there is exactly one rule turning a typed + /// address and a picked type into an address, and both the caption and the + /// request read it from the same place. + public func setEndpoint(_ endpoint: ProviderEndpoint, for id: ModelProvider.ID) { + guard var provider = modelProvider(id) else { return } + provider.inferenceURL = endpoint.requestURL + provider.baseURL = endpoint.baseURL + provider.apiFormat = endpoint.format + endpoints[id] = endpoint + upsert(provider) + } + + /// Removes a provider along with everything that only made sense while it + /// existed: its secrets, the role assignments pointing at it, and the + /// remembered selection if that was one of its models. + /// + /// The same discipline as `HostLibraryModel.delete(identityID:)`. Leaving any of + /// these behind would give `resolve` a ref that looks valid and is not, and + /// the failure would surface at send time rather than here. + public func delete(modelProviderID: ModelProvider.ID) { + guard let provider = modelProvider(modelProviderID) else { return } + + let deleted = mutatePersistedState { + for role in roles(using: modelProviderID) { + roles[role] = nil + } + if lastSelectedModel?.modelProviderID == modelProviderID { + lastSelectedModel = nil + } + modelProviders.removeAll { $0.id == modelProviderID } + endpoints[modelProviderID] = nil + } + guard deleted else { return } + setSecret(nil, for: provider.credentialRef) + } + + public func setModelProviderEnabled(_ enabled: Bool, for id: ModelProvider.ID) { + mutatePersistedState { + guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } + modelProviders[index].isEnabled = enabled + } + } + + // MARK: - Model writes + + /// Replaces the whole model list. + /// + /// Hand-added models are kept and merged rather than wiped: someone who + /// typed a model the endpoint does not advertise — a private deployment, a + /// preview id — should not lose it. A fetched row wins on conflict, since + /// the server is better informed than a guess. + /// + /// Not what the Fetch button calls. A fetch offers a list and the user picks + /// from it (see `ModelListEditor`), so the additions arrive one at a time + /// through `addModel`. This exists for a caller that really does mean "this + /// is now the list". + public func replaceModels(_ fetched: [AIModel], for id: ModelProvider.ID) { + mutatePersistedState { + guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } + let previous = Dictionary( + uniqueKeysWithValues: modelProviders[index].models.map { ($0.id, $0) } + ) + let fetchedIDs = Set(fetched.map(\.id)) + let merged = fetched.map { fresh in + var fresh = fresh + fresh.reasoningProfile = previous[fresh.id]?.reasoningProfile + fresh.isShownInAssistant = + previous[fresh.id]?.isShownInAssistant ?? fresh.isShownInAssistant + return fresh + } + let kept = modelProviders[index].models.filter { + $0.origin == .manual && !fetchedIDs.contains($0.id) + } + modelProviders[index].models = merged + kept + modelProviders[index].modelsFetchedAt = Date() + } + } + + /// Records that the endpoint was asked, whatever the user then chose to add. + /// + /// Separate from adding, because "when did I last check what this endpoint + /// offers" is a different question from "what did I keep", and the answer to + /// the first is useful even when the answer to the second is "nothing". + public func markModelsFetched(for id: ModelProvider.ID) { + mutatePersistedState { + guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } + modelProviders[index].modelsFetchedAt = Date() + } + } + + public func addModel(_ model: AIModel, to id: ModelProvider.ID) { + mutatePersistedState { + guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } + guard !modelProviders[index].models.contains(where: { $0.id == model.id }) else { return } + modelProviders[index].models.append(model) + } + } + + public func updateModel(_ model: AIModel, in id: ModelProvider.ID) { + mutatePersistedState { + guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } + guard let slot = modelProviders[index].models.firstIndex(where: { $0.id == model.id }) else { return } + modelProviders[index].models[slot] = model + } + } + + /// Removes models, and unhooks anything that pointed at them. + /// + /// Same reasoning as `delete(modelProviderID:)`: a role holding a ref to a model + /// that no longer exists is a failure deferred to send time. + public func removeModels(_ ids: Set, from modelProviderID: ModelProvider.ID) { + mutatePersistedState { + guard let index = modelProviders.firstIndex(where: { $0.id == modelProviderID }) else { return } + modelProviders[index].models.removeAll { ids.contains($0.id) } + + for role in AIRole.allCases { + guard let ref = roles[role], ref.modelProviderID == modelProviderID, ids.contains(ref.modelID) else { + continue + } + roles[role] = nil + } + if let ref = lastSelectedModel, ref.modelProviderID == modelProviderID, ids.contains(ref.modelID) { + lastSelectedModel = nil + } + } + } + + // MARK: - Role writes + + public func assign(_ ref: AIModelRef?, to role: AIRole) { + mutatePersistedState { roles[role] = ref } + } + + /// Records a choice made in a conversation's model menu. + /// + /// Not an assignment: it feeds step 3 of `resolve`, so it changes the + /// default without overwriting anything the user set in Settings. + public func select(_ ref: AIModelRef) { + mutatePersistedState { lastSelectedModel = ref } + } + + /// Records a composer permission choice for subsequent unset conversations + /// and future app launches. It does not alter command or MCP execution policy. + public func select(_ permissionMode: AgentPermissionMode) { + lastSelectedPermissionMode = permissionMode + permissionDefaults?.set(permissionMode.rawValue, forKey: "assistant.permissionMode") + } + + // MARK: - Provider persistence + + @discardableResult + private func mutatePersistedState(_ mutation: () -> Void) -> Bool { + let previous = persistedSnapshot + mutation() + guard let persistence else { return true } + do { + try persistence.save(persistedSnapshot) + storageError = nil + return true + } catch { + modelProviders = previous.providers + endpoints = previous.endpoints + roles = previous.roles + lastSelectedModel = previous.lastSelectedModel + mcpServers = previous.mcpServers + storageError = error.localizedDescription + return false + } + } + + private var persistedSnapshot: AIConfigurationSnapshot { + AIConfigurationSnapshot( + providers: modelProviders, + endpoints: endpoints, + roles: roles, + lastSelectedModel: lastSelectedModel, + mcpServers: mcpServers + ) + } + + // MARK: - MCP writes + + public func upsert(_ server: MCPServer) { + mutatePersistedState { + if let index = mcpServers.firstIndex(where: { $0.id == server.id }) { + mcpServers[index] = server + } else { + mcpServers.append(server) + } + } + } + + /// Locks a valid, unique draft namespace without changing the UUID identity. + @discardableResult + public func lockMCPNamespace(for id: MCPServer.ID) -> Bool { + guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return false } + if !mcpServers[index].namespaceID.isEmpty { return true } + let namespaceID = mcpServers[index].effectiveNamespaceID + guard !namespaceID.isEmpty, + !hasMCPNamespaceConflict(namespaceID, excluding: id) + else { return false } + return mutatePersistedState { mcpServers[index].namespaceID = namespaceID } + } + + public func delete(serverID: MCPServer.ID) { + guard let server = mcpServer(serverID) else { return } + let deleted = mutatePersistedState { mcpServers.removeAll { $0.id == serverID } } + guard deleted else { return } + setSecret(nil, for: server.credentialRef) + mcpStatuses[serverID] = nil + } + + public func setServerEnabled(_ enabled: Bool, for id: MCPServer.ID) { + mutatePersistedState { + guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } + mcpServers[index].isEnabled = enabled + } + } + + /// What a successful `Connect` writes back. + /// + /// A tool keeps its access policy across rediscovery — otherwise every + /// reconnect would silently undo the decision made about this server. A + /// genuinely new tool arrives at the default, which means always asking. + public func setTools(_ discovered: [MCPTool], version: String, for id: MCPServer.ID) { + mutatePersistedState { + guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } + let previous = Dictionary( + uniqueKeysWithValues: mcpServers[index].tools.map { ($0.id, $0) } + ) + + mcpServers[index].tools = discovered.map { tool in + guard let old = previous[tool.id] else { return tool } + var merged = tool + merged.accessPolicy = old.accessPolicy + return merged + } + mcpServers[index].negotiatedVersion = version + mcpServers[index].lastConnectedAt = Date() + } + } + + public func setToolAccessPolicy( + _ policy: MCPTool.AccessPolicy, tool toolID: String, on serverID: MCPServer.ID + ) { + mutate(toolID, on: serverID) { $0.accessPolicy = policy } + } + + private func mutate( + _ toolID: String, on serverID: MCPServer.ID, + _ change: (inout MCPTool) -> Void + ) { + mutatePersistedState { + guard let index = mcpServers.firstIndex(where: { $0.id == serverID }) else { return } + guard let slot = mcpServers[index].tools.firstIndex(where: { $0.id == toolID }) + else { return } + change(&mcpServers[index].tools[slot]) + } + } + + public func setMCPStatus(_ status: ConnectionStatus, for id: MCPServer.ID) { + mcpStatuses[id] = status + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift new file mode 100644 index 00000000..ff6dc514 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/CredentialLibraryModel.swift @@ -0,0 +1,58 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// The Keychain feature's view of the shared vault. +/// +/// Keeping this surface separate prevents credential screens from mutating hosts, +/// snippets, forwarding rules, or transfers merely because those records share a +/// persistence snapshot. +@Observable +public final class CredentialLibraryModel { + private let library: HostLibraryModel + + public init(library: HostLibraryModel) { + self.library = library + } + + public var hosts: [Host] { library.hosts } + public var keys: [SSHKeyRef] { library.keys } + public var identities: [Identity] { library.identities } + public var storageError: String? { library.storageError } + + public func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) } + public func identities(using key: SSHKeyRef.ID) -> [Identity] { library.identities(using: key) } + public func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } + public func searchHosts(_ query: String) -> [Host] { library.searchHosts(query) } + public func searchKeys(_ query: String) -> [SSHKeyRef] { library.searchKeys(query) } + public func searchIdentities(_ query: String) -> [Identity] { library.searchIdentities(query) } + public func passwordAvailability(for identity: Identity) -> SecretAvailability { + library.passwordAvailability(for: identity) + } + public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { + library.keyAvailability(for: key) + } + public func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { + try library.keyCredential(for: key) + } + public func keyExportUnavailableReason(for host: Host) -> String? { + library.keyExportUnavailableReason(for: host) + } + public func sshConnectionConfiguration(for host: Host) throws -> SSHConnectionConfiguration { + try library.sshConnectionConfiguration(for: host) + } + + public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { + try library.upsert(key, credential: credential) + } + @discardableResult + public func delete(identityID: Identity.ID) -> Bool { library.delete(identityID: identityID) } + public func delete(keyID: SSHKeyRef.ID) throws { try library.delete(keyID: keyID) } + public func move(keyIDs: [SSHKeyRef.ID], before next: SSHKeyRef.ID? = nil) { + library.move(keyIDs: keyIDs, before: next) + } + public func move(identityIDs: [Identity.ID], before next: Identity.ID? = nil) { + library.move(identityIDs: identityIDs, before: next) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift new file mode 100644 index 00000000..9625db4b --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/ForwardingLibraryModel.swift @@ -0,0 +1,42 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// Read and write access needed by the port-forwarding feature. +@Observable +public final class ForwardingLibraryModel { + private let library: HostLibraryModel + + public init(library: HostLibraryModel) { + self.library = library + } + + public var hosts: [Host] { library.hosts } + public var rules: [PortForwardingRule] { library.portForwardings } + + public func save(_ rule: PortForwardingRule) throws { + guard rule.isValid, let host = library.host(rule.hostID) else { throw CocoaError(.coderInvalidValue) } + let rules = library.portForwardings(for: host.id).filter { $0.id != rule.id } + [rule] + guard library.upsert(host, replacingPortForwardings: rules) else { + throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save")) + } + } + + public func delete(_ id: UUID) throws { + guard let rule = library.portForwarding(id), let host = library.host(rule.hostID) else { return } + guard + library.upsert(host, replacingPortForwardings: library.portForwardings(for: host.id).filter { $0.id != id }) + else { + throw TermindSSHError.library(library.storageError ?? String(localized: "Unable to Save")) + } + } + + public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? { + library.portForwarding(id) + } + public func routeSummary(for host: Host) -> String { library.routeSummary(for: host) } + public func sshCommand(for rule: PortForwardingRule, host: Host? = nil) -> String? { + library.sshCommand(for: rule, host: host) + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift new file mode 100644 index 00000000..9789f895 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -0,0 +1,1825 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// The single source of truth for saved hosts, groups, keys, snippets, and port +/// forwarding rules. +/// +/// Core host metadata is backed by SwiftData in the app and projected into value +/// types for the views. Previews can still construct an in-memory store directly. +@Observable +public final class HostLibraryModel { + public private(set) var hosts: [Host] = [] + public private(set) var groups: [HostGroup] = [] + public private(set) var keys: [SSHKeyRef] = [] + public private(set) var identities: [Identity] = [] + public private(set) var snippets: [Snippet] = [] + public private(set) var snippetPackages: [SnippetPackage] = [] + public private(set) var portForwardings: [PortForwardingRule] = [] + public private(set) var sftpTransfers: [SFTPTransfer] = [] + public private(set) var knownHosts: [KnownHostEntry] = [] + + /// The latest local persistence failure. Secret text is never included. + public private(set) var storageError: String? + private let vault: VaultStore + private let keychain: any SecretStoring + /// Runtime resources live in AppModel, so deletion announces before model + /// references are removed and the controller can stop listeners cleanly. + public var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)? + public var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)? + public var didPersistCore: (() -> Void)? { + get { vault.didPersist } + set { vault.didPersist = newValue } + } + public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? { + get { vault.didRecordChanges } + set { vault.didRecordChanges = newValue } + } + + public func reportStorageError(_ error: any Error) { + storageError = error.localizedDescription + } + + /// Live connection state, keyed by host. Lives here rather than on `Host` + /// because it is session state, not user data — it must never be persisted + /// or synced. + public private(set) var statuses: [Host.ID: ConnectionStatus] = [:] + private var connectionStatuses: [UUID: (hostID: Host.ID, status: ConnectionStatus)] = [:] + + /// Whether the Hosts page's Ungrouped section is open. + /// + /// Lives here rather than as view state because a real group keeps its + /// `isExpanded` on the model: if this were a `@State` on the page, folding + /// Ungrouped would spring back open on the next rebuild while folding + /// Production stayed folded, and there would be no way to explain the + /// difference to whoever noticed. + public private(set) var isUngroupedExpanded = true + public private(set) var isSnippetsUngroupedExpanded = true + + public init( + hosts: [Host] = [], + groups: [HostGroup] = [], + keys: [SSHKeyRef] = [], + identities: [Identity] = [], + snippets: [Snippet] = [], + snippetPackages: [SnippetPackage] = [], + portForwardings: [PortForwardingRule] = [], + sftpTransfers: [SFTPTransfer] = [], + knownHosts: [KnownHostEntry] = [], + persistence: (any VaultPersisting)? = nil, + secrets: any SecretStoring = InMemorySecretStore() + ) { + self.hosts = hosts + self.groups = groups + self.keys = normalizedManualOrder( + keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label + ) + self.identities = normalizedManualOrder( + identities, sortIndex: \Identity.sortIndex, name: \Identity.name + ) + self.snippets = normalizedSnippetOrder(snippets) + self.snippetPackages = snippetPackages + self.portForwardings = portForwardings + self.sftpTransfers = sftpTransfers + self.knownHosts = knownHosts + self.vault = VaultStore(persistence: persistence) + self.keychain = secrets + do { + let loaded = try vault.load( + fallback: VaultSnapshot( + hosts: hosts, groups: groups, identities: identities, keys: keys, + knownHosts: knownHosts, snippets: snippets, + snippetPackages: snippetPackages, portForwardings: portForwardings + ) + ) + self.hosts = loaded.hosts + self.groups = loaded.groups + self.identities = loaded.identities.sorted { + manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name) + } + self.keys = loaded.keys.sorted { + manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label) + } + self.knownHosts = loaded.knownHosts + self.snippets = loaded.snippets.sorted { $0.sortIndex < $1.sortIndex } + self.snippetPackages = loaded.snippetPackages + self.portForwardings = loaded.portForwardings + } catch { + storageError = error.localizedDescription + } + } + + // MARK: - Reads + + public func host(_ id: Host.ID) -> Host? { + hosts.first { $0.id == id } + } + + public func group(_ id: HostGroup.ID) -> HostGroup? { + groups.first { $0.id == id } + } + + public func snippet(_ id: Snippet.ID) -> Snippet? { + snippets.first { $0.id == id } + } + + public func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? { + snippetPackages.first { $0.id == id } + } + + public var rootSnippetPackages: [SnippetPackage] { + snippetPackages.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex } + } + + public func childSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { + snippetPackages.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex } + } + + public func descendantSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { + childSnippetPackages(of: parent).flatMap { + [$0] + descendantSnippetPackages(of: $0.id) + } + } + + public func snippets(in package: SnippetPackage.ID?) -> [Snippet] { + snippets.filter { $0.packageID == package }.sorted { $0.sortIndex < $1.sortIndex } + } + + public func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? { + guard let packageID, let package = snippetPackage(packageID) else { return nil } + var names = [package.name] + var parentID = package.parentID + var visited: Set = [package.id] + while let id = parentID, let parent = snippetPackage(id), visited.insert(id).inserted { + names.append(parent.name) + parentID = parent.parentID + } + return names.reversed().joined(separator: " / ") + } + + public func status(of id: Host.ID) -> ConnectionStatus { + if statuses[id] == .connected { return .connected } + if sftpTransfers.contains(where: { + $0.involves(id) && $0.state == .running + }) { + return .connected + } + if let status = statuses[id] { return status } + if sftpTransfers.contains(where: { + $0.involves(id) && $0.state == .queued + }) { + return .connecting + } + return .disconnected + } + + /// Top-level groups, in sort order. + public var rootGroups: [HostGroup] { + groups.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex } + } + + public func childGroups(of parent: HostGroup.ID) -> [HostGroup] { + groups.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex } + } + + /// Every group below `parent`, at any depth, parents before children. + /// + /// What a delete has to take with it, and — read the other way — the set a + /// group may *not* be re-filed into, since a group cannot become its own + /// descendant. + public func descendantGroups(of parent: HostGroup.ID) -> [HostGroup] { + childGroups(of: parent).flatMap { [$0] + descendantGroups(of: $0.id) } + } + + /// Hosts directly inside a group — `nil` means "not filed in any group". + public func hosts(in group: HostGroup.ID?) -> [Host] { + hosts.filter { $0.groupID == group }.sorted { $0.sortIndex < $1.sortIndex } + } + + /// Hosts anywhere in a group's subtree. The number a delete has to warn + /// about: deleting Production takes Edge with it, so the machines filed in + /// Edge are just as much at stake as the ones filed in Production. + public func hosts(inTreeOf group: HostGroup.ID) -> [Host] { + let tree = Set([group] + descendantGroups(of: group).map(\.id)) + return hosts.filter { host in host.groupID.map(tree.contains) ?? false } + } + + public func portForwardings(for host: Host.ID) -> [PortForwardingRule] { + portForwardings.filter { $0.hostID == host } + } + + public func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? { + portForwardings.first { $0.id == id } + } + + public func sftpTransfers(for host: Host.ID) -> [SFTPTransfer] { + sftpTransfers.filter { $0.involves(host) } + } + + public func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { + keys.first { $0.id == id } + } + + public func identity(_ id: Identity.ID) -> Identity? { + identities.first { $0.id == id } + } + + /// The login a host connects with, resolved through the store so renaming an + /// identity renames it everywhere it is shown. + public func identity(for host: Host) -> Identity? { + host.identityID.flatMap { identity($0) } + } + + /// `user@host:port` — what the UI shows almost everywhere a host is named. + /// + /// Falls back to the bare address when no identity is set, rather than + /// inventing a login: a host that does not yet know who it connects as + /// should look like one. + public func loginAddress(for host: Host) -> String { + guard let username = identity(for: host)?.username, !username.isEmpty else { + return host.displayAddress + } + return "\(username)@\(host.displayAddress)" + } + + /// Which identities would break if a key were deleted. The Keys list shows + /// this count, because "delete" needs to say what it takes with it. + public func identities(using key: SSHKeyRef.ID) -> [Identity] { + identities.filter { $0.keyID == key } + } + + /// The pinned host keys for a saved host, matched the way OpenSSH matches: + /// hostname plus port. Newest keys are shown first in the host's details. + public func knownHosts(for host: Host) -> [KnownHostEntry] { + knownHosts + .filter { $0.matches(hostname: host.host, port: host.port) } + .sorted { + if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt } + if $0.fingerprint != $1.fingerprint { return $0.fingerprint < $1.fingerprint } + return $0.id.uuidString < $1.id.uuidString + } + } + + /// Resolves persistent trust plus approvals scoped to the current connection + /// journey. Temporary approvals never enter SwiftData or sync. + public func expectedHostKeys( + for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] + ) throws -> Set { + var keys = Set(knownHosts(for: host).map(\.identity)) + if let temporary = temporaryHostKeys[host.id] { + keys.insert(temporary) + } + guard !keys.isEmpty else { throw TermindSSHError.untrustedHost } + return keys + } + + /// Why a saved host cannot currently receive a key without stopping for + /// another question. Export is deliberately limited to hosts whose login + /// and host-key trust have already been established elsewhere in the app. + public func keyExportUnavailableReason(for host: Host) -> String? { + guard let identity = identity(for: host) else { + return String(localized: "No identity") + } + guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return String(localized: "No username") + } + let hasAvailablePassword = passwordAvailability(for: identity) == .available + let hasUsableKey = + identity.keyID.flatMap { key($0) }.map { + keyAvailability(for: $0) == .available + } == true + guard hasAvailablePassword || hasUsableKey else { + return String(localized: "No saved credential") + } + let knownHosts = knownHosts(for: host) + guard !knownHosts.isEmpty else { + return String(localized: "Connect once to trust this host") + } + guard !knownHosts.contains(where: \.isMismatched) else { + return String(localized: "Host key changed") + } + return nil + } + + /// Resolves model references and Keychain secrets before work leaves the + /// main actor. The resulting value is Sendable and contains no persistence + /// objects, so several one-shot exports can safely run in parallel. + public func prepareSSHConnection( + for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission] + ) throws -> SSHConnectionPreparation { + guard let route = validatedRoute(for: host) else { + throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) + } + return SSHConnectionPreparation( + nodes: route.map { node in + let submission = submissions[node.id] + let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node) + let keyID = submission != nil ? submission?.keyID : identity?.keyID + return SSHConnectionPreparation.Node( + host: node, identity: identity, + key: keyID.flatMap { key($0) }, + submission: submission, trustedKeys: Set(knownHosts(for: node).map(\.identity)), + proxyIdentity: node.proxy?.identityID.flatMap { self.identity($0) } + ) + }, + secrets: keychain + ) + } + + public func sshConnectionConfiguration( + for host: Host, + submissions: [Host.ID: SSHAuthenticationSubmission] = [:], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] + ) throws -> SSHConnectionConfiguration { + let username: String + let credentials: [SSHCredentials] + if let submission = submissions[host.id] { + username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + credentials = try sshCredentials(for: submission) + } else { + guard let identity = identity(for: host) else { + throw TermindSSHError.missingCredential + } + username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + let resolution = sshCredentials(for: identity) + guard resolution.requiredPassphraseKeyID == nil else { + throw TermindSSHError.keyPassphraseRequired + } + credentials = resolution.credentials + } + guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } + let expectedHostKeys = try expectedHostKeys( + for: host, temporaryHostKeys: temporaryHostKeys + ) + + return SSHConnectionConfiguration( + hostID: host.id, host: host.host, port: host.port, username: username, + credentials: credentials, + expectedHostKeys: expectedHostKeys, + proxy: try transportProxy(for: host), + jumps: try sshJumpConfigurations( + for: host, submissions: submissions, + temporaryHostKeys: temporaryHostKeys + ) + ) + } + + public func sshJumpConfigurations( + for host: Host, + submissions: [Host.ID: SSHAuthenticationSubmission], + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] + ) throws -> [SSHRouteHopConfiguration] { + guard let route = validatedRoute(for: host) else { + throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) + } + return try route.dropLast().enumerated().map { offset, jump in + do { + if let submission = submissions[jump.id] { + return try sshRouteHopConfiguration( + for: jump, submission: submission, + temporaryHostKeys: temporaryHostKeys + ) + } + return try sshRouteHopConfiguration( + for: jump, temporaryHostKeys: temporaryHostKeys + ) + } catch { + let authentication = + switch error as? TermindSSHError { + case .missingCredential, .missingUsername, .keyPassphraseRequired: true + default: false + } + let hostKey = + switch error as? TermindSSHError { + case .untrustedHost: true + default: false + } + throw TermindSSHError.routeHost( + hostID: jump.id, authentication: authentication, + serverChallenge: false, hostKey: hostKey, + message: String(localized: "Jump Host \(offset + 1) (\(jump.name)): \(error.localizedDescription)") + ) + } + } + } + + private func sshRouteHopConfiguration( + for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> SSHRouteHopConfiguration { + guard let identity = identity(for: host) else { throw TermindSSHError.missingCredential } + let username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + let expectedHostKeys = try expectedHostKeys( + for: host, temporaryHostKeys: temporaryHostKeys + ) + let resolution = sshCredentials(for: identity) + guard resolution.requiredPassphraseKeyID == nil else { + throw TermindSSHError.keyPassphraseRequired + } + guard !resolution.credentials.isEmpty else { throw TermindSSHError.missingCredential } + return SSHRouteHopConfiguration( + id: host.id, name: host.name, host: host.host, port: host.port, + username: username, credentials: resolution.credentials, + expectedHostKeys: expectedHostKeys, + proxy: try transportProxy(for: host) + ) + } + + private func sshRouteHopConfiguration( + for host: Host, submission: SSHAuthenticationSubmission, + temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] + ) throws -> SSHRouteHopConfiguration { + let username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !username.isEmpty else { throw TermindSSHError.missingUsername } + let expectedHostKeys = try expectedHostKeys( + for: host, temporaryHostKeys: temporaryHostKeys + ) + let credentials = try sshCredentials(for: submission) + guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } + return SSHRouteHopConfiguration( + id: host.id, name: host.name, host: host.host, port: host.port, + username: username, credentials: credentials, + expectedHostKeys: expectedHostKeys, + proxy: try transportProxy(for: host) + ) + } + + private func sshCredentials( + for submission: SSHAuthenticationSubmission + ) throws -> [SSHCredentials] { + var credentials: [SSHCredentials] = [] + if let keyID = submission.keyID, let key = key(keyID) { + if key.requiresPassphraseEntry, + submission.passphrase?.isEmpty != false + { + throw TermindSSHError.keyPassphraseRequired + } + if let stored = try keyCredential(for: key) { + credentials.append( + stored.sshCredential( + publicKey: key.publicKey, certificate: key.certificate, + passphraseOverride: submission.passphrase + ) + ) + } + } + if let password = submission.password { credentials.append(.password(password)) } + return credentials + } + + public func transportProxy(for host: Host) throws -> SSHTransportProxy? { + guard let proxy = host.proxy else { return nil } + guard proxy.isValid else { + throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) + } + var password: String? + if let identityID = proxy.identityID { + guard let identity = identity(identityID), identity.usesPassword else { + throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable.")) + } + password = try passwordCredential(for: identity) + guard password != nil else { + throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) + } + } + return SSHTransportProxy( + kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, + username: proxy.username, password: password + ) + } + + /// Loads Key and Password independently so one damaged or missing Keychain + /// item does not suppress the other usable credential. + public func sshCredentials(for identity: Identity) -> SSHCredentialResolution { + var credentials: [SSHCredentials] = [] + var failures: [String] = [] + var requiredPassphraseKeyID: SSHKeyRef.ID? + + if let keyID = identity.keyID { + if let key = key(keyID) { + do { + if let credential = try keyCredential(for: key) { + if key.requiresPassphraseEntry { + requiredPassphraseKeyID = key.id + } else { + credentials.append( + credential.sshCredential( + publicKey: key.publicKey, certificate: key.certificate + ) + ) + } + } else { + failures.append( + String(localized: "The selected key has no saved private key.") + ) + } + } catch { + storageError = error.localizedDescription + failures.append(error.localizedDescription) + } + } else { + failures.append( + String(localized: "The selected key is no longer available.") + ) + } + } + + if identity.usesPassword { + do { + if let password = try passwordCredential(for: identity) { + credentials.append(.password(password)) + } else { + failures.append( + String(localized: "The saved password is no longer available.") + ) + } + } catch { + storageError = error.localizedDescription + failures.append(error.localizedDescription) + } + } + + return SSHCredentialResolution( + credentials: credentials, failures: failures, + hasConfiguredCredential: identity.hasSecret, + requiredPassphraseKeyID: requiredPassphraseKeyID + ) + } + + /// Every host tag, deduplicated case-insensitively and sorted. + public var allTags: [String] { + uniqueSortedTags(hosts.flatMap(\.tags)) + } + + /// Tags in use on hosts. What the Hosts page offers to filter by: a + /// snippet-only tag there would be a filter that matches nothing. + public var hostTags: [String] { + uniqueSortedTags(hosts.flatMap(\.tags)) + } + + private func uniqueSortedTags(_ tags: [String]) -> [String] { + var seen = Set() + let uniqueTags = tags.filter { seen.insert($0.lowercased()).inserted } + return uniqueTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } + } + + // MARK: - Search + + /// Also matches the resolved identity, so filtering by `deploy` still finds + /// every box that logs in as `deploy` now that the name lives one hop away. + public func searchHosts(_ query: String) -> [Host] { + hosts + .filter { host in + host.matches(query) + || loginAddress(for: host).localizedCaseInsensitiveContains(query) + || (!query.isEmpty && identity(for: host)?.matches(query) == true) + } + .sorted { $0.name < $1.name } + } + + public func searchSnippets(_ query: String) -> [Snippet] { + snippets.filter { + $0.matches(query) + || (!query.isEmpty + && snippetPackagePath($0.packageID)?.localizedCaseInsensitiveContains(query) == true) + }.sorted { $0.sortIndex < $1.sortIndex } + } + + public func searchKeys(_ query: String) -> [SSHKeyRef] { + keys + .filter { key in + query.isEmpty + || key.label.localizedCaseInsensitiveContains(query) + || key.fingerprint.localizedCaseInsensitiveContains(query) + } + .sorted { manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label) } + } + + public func searchIdentities(_ query: String) -> [Identity] { + identities.filter { $0.matches(query) }.sorted { + manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name) + } + } + + // MARK: - Writes + + public func upsert(_ host: Host) { + guard validatedRoute(for: host) != nil else { + storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.") + return + } + if let index = hosts.firstIndex(where: { $0.id == host.id }) { + var updated = host + updated.detectedDistribution = hosts[index].detectedDistribution + hosts[index] = updated + } else { + var new = host + new.sortIndex = nextSortIndex(in: host.groupID) + hosts.append(new) + } + persistCore() + } + + /// Commits one host and its complete editor-owned collections together. + /// + /// `HostEditorView` owns the Host, forwarding rules, and Known Hosts as + /// drafts, so persisting them through separate calls would let Save only + /// partly succeed. The in-memory projections are + /// restored if SwiftData rejects the replacement, and rules whose live + /// connection would no longer match the saved configuration are stopped + /// before their old records disappear. + @discardableResult + public func upsert( + _ host: Host, + replacingPortForwardings rules: [PortForwardingRule] + ) -> Bool { + guard validatedRoute(for: host) != nil else { + storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.") + return false + } + let normalizedRules = rules.map { rule in + var normalized = rule + normalized.hostID = host.id + if normalized.kind == .dynamic { + normalized.targetHost = nil + normalized.targetPort = nil + } + return normalized + } + guard normalizedRules.allSatisfy(\.isValid), + Set(normalizedRules.map(\.id)).count == normalizedRules.count + else { + storageError = String(localized: "One or more forwarding rules are incomplete.") + return false + } + + let oldHosts = hosts + let oldRules = portForwardings + let existingHost = self.host(host.id) + let existingRules = portForwardings(for: host.id) + let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) }) + var rulesToStop = Set( + existingRules.compactMap { old in nextByID[old.id] == old ? nil : old.id } + ) + if let existingHost, existingHost.sshConnectionFields != host.sshConnectionFields { + rulesToStop.formUnion(existingRules.map(\.id)) + } + if !rulesToStop.isEmpty { willDeletePortForwardings?(Array(rulesToStop)) } + + if let index = hosts.firstIndex(where: { $0.id == host.id }) { + var updated = host + updated.detectedDistribution = hosts[index].detectedDistribution + hosts[index] = updated + } else { + var new = host + new.sortIndex = nextSortIndex(in: host.groupID) + hosts.append(new) + } + portForwardings.removeAll { $0.hostID == host.id } + portForwardings.append(contentsOf: normalizedRules) + guard persistCore() else { + hosts = oldHosts + portForwardings = oldRules + return false + } + return true + } + + /// Transitional in-memory projection for repository-owned durable deletion. + /// The repository is the only writer; this keeps legacy views in sync until + /// they consume `HostLibraryModel` directly. + public func projectHostDeletion(_ hostID: Host.ID) { + removeHostProjection(hostID) + } + + private func removeHostProjection(_ hostID: Host.ID) { + hosts.removeAll { $0.id == hostID } + for index in hosts.indices where hosts[index].jumpHostID == hostID { + hosts[index].jumpHostID = nil + } + for index in snippets.indices { + snippets[index].targetHostIDs.removeAll { $0 == hostID } + } + portForwardings.removeAll { $0.hostID == hostID } + connectionStatuses = connectionStatuses.filter { $0.value.hostID != hostID } + statuses[hostID] = nil + } + + public func setStatus(_ status: ConnectionStatus, for id: Host.ID) { + statuses[id] = status + } + + /// Records one live connection's state and projects terminals and File + /// Transfer browsers for the same host into the status dot shown by Hosts + /// and host pickers. A connected source wins over a connecting or failed + /// sibling, so closing one pane cannot make an otherwise-online host appear + /// offline. + public func setConnectionStatus( + _ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID + ) { + if status == .disconnected { + connectionStatuses[connectionID] = nil + } else { + connectionStatuses[connectionID] = (hostID, status) + } + refreshStatus(for: hostID) + } + + /// Persists only a meaningful, validated distribution transition. The + /// current Host projection is mutated in place so a shell result cannot + /// overwrite editor changes made while the connection was starting. + public func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) { + guard let index = hosts.firstIndex(where: { $0.id == hostID }), + let systemInfo, let kernelName = systemInfo.kernelName + else { return } + + let next: DetectedDistribution? + if kernelName.caseInsensitiveCompare("Linux") == .orderedSame { + guard let id = systemInfo.distributionID, + let detected = DetectedDistribution( + id: id, likeIDs: systemInfo.distributionLikeIDs + ) + else { return } + next = detected + } else { + next = nil + } + + guard hosts[index].detectedDistribution != next else { return } + hosts[index].detectedDistribution = next + persistCore() + } + + private func refreshStatus(for hostID: Host.ID) { + let active = connectionStatuses.values + .filter { $0.hostID == hostID } + .map(\.status) + if active.contains(.connected) { + statuses[hostID] = .connected + } else if active.contains(.reconnecting) { + statuses[hostID] = .reconnecting + } else if active.contains(.connecting) { + statuses[hostID] = .connecting + } else if let failure = active.first(where: { + if case .failed = $0 { return true } + return false + }) { + statuses[hostID] = failure + } else { + statuses[hostID] = nil + } + } + + /// Drops a pinned host key. The next connection to that host is treated as a + /// first connection, which is the whole point — it is the escape hatch for a + /// server that was legitimately rekeyed. + public func forget(knownHost id: KnownHostEntry.ID) { + deleteKnownHosts([id]) + } + + public func forgetKnownHosts(for host: Host) { + deleteKnownHosts(Set(knownHosts(for: host).map(\.id))) + } + + public func trust(_ entry: KnownHostEntry) { + let endpointMatches: (KnownHostEntry) -> Bool = { + $0.matches(hostname: entry.hostname, port: entry.port) + } + for index in knownHosts.indices where endpointMatches(knownHosts[index]) { + knownHosts[index].isMismatched = false + } + if let index = knownHosts.firstIndex(where: { + endpointMatches($0) && $0.fingerprint == entry.fingerprint + }) { + let retainedID = knownHosts[index].id + knownHosts[index].algorithm = entry.algorithm + knownHosts[index].addedAt = entry.addedAt + knownHosts.removeAll { + $0.id != retainedID && endpointMatches($0) + && $0.fingerprint == entry.fingerprint + } + } else { + knownHosts.append(entry) + } + persistCore() + } + + public func markKnownHostMismatched(hostname: String, port: Int) { + var changed = false + for index in knownHosts.indices + where knownHosts[index].matches(hostname: hostname, port: port) { + knownHosts[index].isMismatched = true + changed = true + } + if changed { persistCore() } + } + + public func setExpanded(_ expanded: Bool, for groupID: HostGroup.ID) { + guard let index = groups.firstIndex(where: { $0.id == groupID }) else { return } + groups[index].isExpanded = expanded + persistCore() + } + + public func setUngroupedExpanded(_ expanded: Bool) { + isUngroupedExpanded = expanded + } + + public func setSnippetPackageExpanded(_ expanded: Bool, for packageID: SnippetPackage.ID) { + guard let index = snippetPackages.firstIndex(where: { $0.id == packageID }) else { return } + snippetPackages[index].isExpanded = expanded + persistCore() + } + + public func setSnippetsUngroupedExpanded(_ expanded: Bool) { + isSnippetsUngroupedExpanded = expanded + } + + /// Adds or updates an identity. New ones land at the end of the list, which + /// is sorted by name wherever it is shown, so there is no order to keep. + @discardableResult + public func upsert(_ identity: Identity) -> Bool { + if let index = identities.firstIndex(where: { $0.id == identity.id }) { + var updated = identity + updated.sortIndex = identities[index].sortIndex + identities[index] = updated + } else { + var new = identity + new.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1 + identities.append(new) + } + if !identity.isProxyEligible { + for index in hosts.indices where hosts[index].proxy?.identityID == identity.id { + hosts[index].proxy?.identityID = nil + hosts[index].proxy?.username = "" + } + } + return persistCore() + } + + /// Updates an Identity and its Keychain password as one operation. + /// + /// The editor supplies intent rather than an optional string so a blank + /// field can keep an existing password while an explicit remove action can + /// delete it. The old reference is resolved here, before the model changes, + /// which prevents a cleared Identity from making its former Keychain item + /// unreachable. + public func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws { + let previousIdentity = self.identity(identity.id) + let previousReference = previousIdentity?.credentialRef ?? identity.credentialRef + let previousStorage = previousIdentity?.passwordStorage ?? identity.passwordStorage + var updated = identity + + let oldPassword = + previousReference.isEmpty + ? nil : try keychain.string(for: previousReference, storage: previousStorage) + let identitySnapshot = identities + let hostSnapshot = hosts + var wroteDestination = false + do { + switch passwordUpdate { + case .keep: + updated.credentialRef = previousReference + if previousStorage != updated.passwordStorage, !previousReference.isEmpty { + guard let oldPassword else { + throw TermindSSHError.library( + String( + localized: + "The saved password is unavailable on this Mac. Enter it again before changing storage." + ) + ) + } + try keychain.setString( + oldPassword, for: previousReference, storage: updated.passwordStorage + ) + wroteDestination = true + } + case .replace(let password): + guard !password.isEmpty else { + throw TermindSSHError.library( + String(localized: "A replacement password cannot be empty.") + ) + } + let reference = + previousReference.isEmpty + ? "termind.password.\(UUID().uuidString)" + : previousReference + try keychain.setString(password, for: reference, storage: updated.passwordStorage) + wroteDestination = true + updated.credentialRef = reference + case .remove: + updated.credentialRef = "" + } + storageError = nil + } catch { + storageError = error.localizedDescription + throw error + } + + guard upsert(updated) else { + identities = identitySnapshot + hosts = hostSnapshot + do { + if wroteDestination { + if updated.passwordStorage == previousStorage, let oldPassword { + try keychain.setString( + oldPassword, for: previousReference, storage: previousStorage + ) + } else if !updated.credentialRef.isEmpty { + try keychain.removePassword( + updated.credentialRef, storage: updated.passwordStorage + ) + } + } + } catch { + storageError = String( + localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" + ) + } + throw TermindSSHError.library( + storageError ?? String(localized: "The identity could not be saved.") + ) + } + + let removesPrevious = + !previousReference.isEmpty + && (updated.credentialRef.isEmpty || previousStorage != updated.passwordStorage) + if removesPrevious { + do { + try keychain.removePassword(previousReference, storage: previousStorage) + } catch { + // The newly committed destination stays authoritative. Retaining + // the source is safer than rolling a successful save backward. + storageError = String( + localized: + "The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)" + ) + } + } + } + + /// Throwing counterpart used by connection setup, where a Keychain failure + /// must remain distinguishable from an Identity that has no password. + public func passwordCredential(for identity: Identity) throws -> String? { + guard identity.usesPassword else { return nil } + return try keychain.string( + for: identity.credentialRef, storage: identity.passwordStorage + ) + } + + public func passwordAvailability(for identity: Identity) -> SecretAvailability { + guard identity.usesPassword else { return .notConfigured } + do { + return try passwordCredential(for: identity) == nil ? .missing : .available + } catch { + return .failed(error.localizedDescription) + } + } + + public func isProxyEligibleOnThisDevice(_ identity: Identity) -> Bool { + identity.isProxyEligible && passwordAvailability(for: identity) == .available + } + + /// Removes an identity, along with the password it was the only way to + /// reach, and unfiles it from every host that logged in with it — those + /// hosts keep their address and go back to "no login chosen yet". + @discardableResult + public func delete(identityID: Identity.ID) -> Bool { + guard let identity = identity(identityID) else { return false } + let password: String? + do { + password = + identity.credentialRef.isEmpty + ? nil + : try keychain.string( + for: identity.credentialRef, storage: identity.passwordStorage + ) + try keychain.removePassword( + identity.credentialRef, storage: identity.passwordStorage + ) + } catch { + storageError = error.localizedDescription + return false + } + let identitySnapshot = identities + let hostSnapshot = hosts + identities.removeAll { $0.id == identityID } + for index in hosts.indices where hosts[index].identityID == identityID { + hosts[index].identityID = nil + } + for index in hosts.indices where hosts[index].proxy?.identityID == identityID { + hosts[index].proxy?.identityID = nil + hosts[index].proxy?.username = "" + } + guard persistCore() else { + identities = identitySnapshot + hosts = hostSnapshot + if let password { + do { + try keychain.setString( + password, for: identity.credentialRef, + storage: identity.passwordStorage + ) + } catch { + storageError = String( + localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" + ) + } + } + return false + } + return true + } + + public func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { + let previousKey = self.key(key.id) + if key.storage == .secureEnclave, !key.certificate.isEmpty { + throw TermindSSHError.library( + String(localized: "Secure Enclave keys cannot store an SSH certificate.") + ) + } + if !key.certificate.isEmpty { + _ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey) + } + let keySnapshot = keys + let changesLocation = + previousKey.map { + $0.storage != key.storage || $0.credentialRef != key.credentialRef + } ?? false + if changesLocation, + previousKey?.storage == .secureEnclave || key.storage == .secureEnclave + { + throw TermindSSHError.library( + String(localized: "Secure Enclave keys cannot be moved to another storage location.") + ) + } + let previousCredential = + try (credential != nil || changesLocation) + ? previousKey.flatMap { try keyCredential(for: $0) } + : nil + let destinationCredential = credential ?? (changesLocation ? previousCredential : nil) + if let destinationCredential { + try keychain.setKeyCredential( + destinationCredential, for: key.credentialRef, storage: key.storage + ) + } + if let index = keys.firstIndex(where: { $0.id == key.id }) { + var updated = key + updated.sortIndex = keys[index].sortIndex + keys[index] = updated + } else { + var new = key + new.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1 + keys.append(new) + } + guard persistCore() else { + keys = keySnapshot + do { + if changesLocation, destinationCredential != nil { + try keychain.removeKeyCredential( + key.credentialRef, storage: key.storage + ) + } else if destinationCredential != nil, let previousKey, let previousCredential { + try keychain.setKeyCredential( + previousCredential, for: previousKey.credentialRef, + storage: previousKey.storage + ) + } else if destinationCredential != nil { + try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) + } + } catch { + storageError = String( + localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" + ) + } + throw TermindSSHError.library( + storageError ?? String(localized: "The key could not be saved.") + ) + } + + if changesLocation, let previousKey { + do { + try keychain.removeKeyCredential( + previousKey.credentialRef, storage: previousKey.storage + ) + } catch { + storageError = String( + localized: + "The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)" + ) + } + } + } + + /// Reorders Keys without allowing them to leave their fixed Keychain + /// section. The supplied order is the order of a multi-card drag. + public func move(keyIDs: [SSHKeyRef.ID], before next: SSHKeyRef.ID? = nil) { + guard + reorderManually( + keyIDs, before: next, items: &keys, sortIndex: \SSHKeyRef.sortIndex + ) + else { return } + persistCore() + } + + /// Reorders Identities independently from Keys. A mixed Keychain selection + /// is split by the page before it reaches this fixed-section operation. + public func move(identityIDs: [Identity.ID], before next: Identity.ID? = nil) { + guard + reorderManually( + identityIDs, before: next, items: &identities, + sortIndex: \Identity.sortIndex + ) + else { return } + persistCore() + } + + public func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { + try keychain.keyCredential(for: key.credentialRef, storage: key.storage) + } + + public func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { + do { + return try keyCredential(for: key) == nil ? .missing : .available + } catch { + return .failed(error.localizedDescription) + } + } + + public func delete(keyID: SSHKeyRef.ID) throws { + guard let key = key(keyID) else { return } + let credential = try keyCredential(for: key) + try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) + let keySnapshot = keys + let identitySnapshot = identities + keys.removeAll { $0.id == keyID } + for index in identities.indices where identities[index].keyID == keyID { + identities[index].keyID = nil + } + guard persistCore() else { + keys = keySnapshot + identities = identitySnapshot + if let credential { + try keychain.setKeyCredential( + credential, for: key.credentialRef, storage: key.storage + ) + } + throw TermindSSHError.library( + storageError ?? String(localized: "The key could not be deleted.") + ) + } + } + + /// Adds or updates a group. A new group is placed after its last sibling + /// rather than at the end of the flat array — `sortIndex` is per-parent, so + /// a child of Production has to be numbered against Production's children. + public func upsert(_ group: HostGroup) { + if let index = groups.firstIndex(where: { $0.id == group.id }) { + groups[index] = group + } else { + var new = group + new.sortIndex = nextSortIndex(under: group.parentID) + groups.append(new) + } + persistCore() + } + + /// Deletes a group and every group beneath it. + /// + /// Removes a group subtree after callers have durably requested deletion + /// for any hosts they do not intend to keep. + public func deleteGroupProjection(_ groupID: HostGroup.ID) { + let doomed = Set([groupID] + descendantGroups(of: groupID).map(\.id)) + for index in hosts.indices where hosts[index].groupID.map(doomed.contains) ?? false { + hosts[index].groupID = nil + } + groups.removeAll { doomed.contains($0.id) } + persistCore() + } + + public func upsert(_ snippet: Snippet) { + if let index = snippets.firstIndex(where: { $0.id == snippet.id }) { + var updated = snippet + if snippets[index].packageID == updated.packageID { + updated.sortIndex = snippets[index].sortIndex + } else { + updated.sortIndex = nextSnippetSortIndex(in: updated.packageID) + } + snippets[index] = updated + } else { + var new = snippet + new.sortIndex = nextSnippetSortIndex(in: snippet.packageID) + snippets.append(new) + } + persistCore() + } + + public func delete(snippetIDs: [Snippet.ID]) { + let doomed = Set(snippetIDs) + snippets.removeAll { doomed.contains($0.id) } + persistCore() + } + + public func upsert(_ package: SnippetPackage) { + if let index = snippetPackages.firstIndex(where: { $0.id == package.id }) { + let forbidden = Set( + [package.id] + descendantSnippetPackages(of: package.id).map(\.id) + ) + guard package.parentID.map(forbidden.contains) != true else { return } + var updated = package + if snippetPackages[index].parentID != updated.parentID { + updated.sortIndex = nextSnippetPackageSortIndex(under: updated.parentID) + } + snippetPackages[index] = updated + } else { + guard package.parentID != package.id else { return } + var new = package + new.sortIndex = nextSnippetPackageSortIndex(under: package.parentID) + snippetPackages.append(new) + } + expandSnippetPackageAncestors(package.id) + persistCore() + } + + public func delete(snippetPackageID: SnippetPackage.ID, keepSnippets: Bool) { + let doomed = Set( + [snippetPackageID] + descendantSnippetPackages(of: snippetPackageID).map(\.id) + ) + if keepSnippets { + for index in snippets.indices where snippets[index].packageID.map(doomed.contains) == true { + snippets[index].packageID = nil + snippets[index].sortIndex = nextSnippetSortIndex(in: nil) + } + } else { + snippets.removeAll { $0.packageID.map(doomed.contains) == true } + } + snippetPackages.removeAll { doomed.contains($0.id) } + persistCore() + } + + public func move( + snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?, + before next: Snippet.ID? = nil + ) { + if let packageID, snippetPackage(packageID) == nil { return } + guard + let placement = manualOrderPlacement( + snippetIDs, before: next, + destinationItems: snippets(in: packageID), + validIDs: Set(snippets.map(\.id)), sortIndex: \Snippet.sortIndex + ) + else { return } + if placement.moving.allSatisfy({ snippet($0)?.packageID == packageID }) + && snippets(in: packageID).map(\.id) == placement.resulting + { + return + } + + let moving = Set(placement.moving) + for index in snippets.indices { + if moving.contains(snippets[index].id) { snippets[index].packageID = packageID } + if let position = placement.positions[snippets[index].id] { + snippets[index].sortIndex = position + } + } + expandSnippetPackageAncestors(packageID) + persistCore() + } + + public func upsert(_ forwarding: PortForwardingRule) { + guard forwarding.isValid, host(forwarding.hostID) != nil else { return } + var normalized = forwarding + if normalized.kind == .dynamic { + normalized.targetHost = nil + normalized.targetPort = nil + } + if let index = portForwardings.firstIndex(where: { $0.id == normalized.id }) { + portForwardings[index] = normalized + } else { + portForwardings.append(normalized) + } + persistCore() + } + + /// Root jump first and destination last. A nil result means the proposed + /// host closes a cycle, references a missing jump, or exceeds eight hosts. + public func validatedRoute(for proposed: Host) -> [Host]? { + var route: [Host] = [] + var cursor: Host? = proposed + var visited: Set = [] + while let current = cursor { + guard visited.insert(current.id).inserted, route.count < 8 else { return nil } + route.append(current) + guard let jumpID = current.jumpHostID else { break } + if jumpID == proposed.id { return nil } + cursor = jumpID == proposed.id ? proposed : host(jumpID) + if cursor == nil { return nil } + } + return route.reversed() + } + + public func route(for host: Host) -> [Host] { + validatedRoute(for: host) ?? [host] + } + + public func routeSummary(for host: Host) -> String { + route(for: host).map { node in + if let proxy = node.proxy { + return "\(proxy.kind.displayName) \(proxy.hostname):\(proxy.port) → \(node.displayAddress)" + } + return node.displayAddress + }.joined(separator: " → ") + } + + /// OpenSSH equivalent when the route needs no in-process Proxy. Passwords, + /// private keys, and proxy credentials never enter the clipboard. + public func sshCommand(for host: Host) -> String? { + sshCommand(for: host, additionalArguments: []) + } + + public func sshCommand(for rule: PortForwardingRule, host proposedHost: Host? = nil) -> String? { + guard let destination = proposedHost ?? host(rule.hostID), destination.id == rule.hostID + else { return nil } + var arguments = ["-N"] + switch rule.kind { + case .local: + guard let host = rule.targetHost, let port = rule.targetPort else { return nil } + arguments += ["-L", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"] + case .remote: + guard let host = rule.targetHost, let port = rule.targetPort else { return nil } + arguments += ["-R", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"] + case .dynamic: + arguments += ["-D", rule.bindAddress] + } + return sshCommand(for: destination, additionalArguments: arguments) + } + + private func sshCommand(for destination: Host, additionalArguments: [String]) -> String? { + guard let route = validatedRoute(for: destination), + route.allSatisfy({ $0.proxy == nil }) + else { return nil } + + let jumps = route.dropLast().map { node in + Self.openSSHAddress( + host: node.host, port: node.port, + username: identity(for: node)?.username, bracketsIPv6: true + ) + } + var arguments = ["ssh"] + additionalArguments + if !jumps.isEmpty { arguments += ["-J", jumps.joined(separator: ",")] } + if destination.port != 22 { arguments += ["-p", String(destination.port)] } + arguments.append( + Self.openSSHAddress( + host: destination.host, port: 22, + username: identity(for: destination)?.username, bracketsIPv6: false + ) + ) + return arguments.map(Self.shellQuoted).joined(separator: " ") + } + + private static func openSSHAddress( + host: String, port: Int, username: String?, bracketsIPv6: Bool + ) -> String { + let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines) + let resolvedHost = bracketsIPv6 && host.contains(":") ? "[\(host)]" : host + let address = port == 22 ? resolvedHost : "\(resolvedHost):\(port)" + guard let trimmedUsername, !trimmedUsername.isEmpty else { return address } + return "\(trimmedUsername)@\(address)" + } + + /// POSIX-shell quoting for a single argument. Common SSH punctuation stays + /// readable; everything else uses single quotes without losing apostrophes. + private static func shellQuoted(_ value: String) -> String { + let safe = CharacterSet( + charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") + if !value.isEmpty, value.unicodeScalars.allSatisfy(safe.contains) { return value } + return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" + } + + private static func commandAddress(_ host: String, port: Int) -> String { + "\(host.contains(":") ? "[\(host)]" : host):\(port)" + } + + public func setSFTPTransferState(_ state: SFTPTransfer.State, for id: SFTPTransfer.ID) { + guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return } + sftpTransfers[index].state = state + } + + public func enqueueSFTPTransfer(_ sftpTransfer: SFTPTransfer) { + sftpTransfers.append(sftpTransfer) + } + + public func sftpTransfer(_ id: SFTPTransfer.ID) -> SFTPTransfer? { + sftpTransfers.first { $0.id == id } + } + + public func updateSFTPTransfer( + _ id: SFTPTransfer.ID, completedBytes: Int64, totalBytes: Int64, + bytesPerSecond: Int64 + ) { + guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return } + sftpTransfers[index].transferredBytes = completedBytes + sftpTransfers[index].byteCount = totalBytes + sftpTransfers[index].bytesPerSecond = bytesPerSecond + } + + public func finishSFTPTransfer(_ id: SFTPTransfer.ID, state: SFTPTransfer.State) { + guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return } + sftpTransfers[index].state = state + sftpTransfers[index].bytesPerSecond = 0 + sftpTransfers[index].completedAt = Date() + if state == .completed { + sftpTransfers[index].transferredBytes = sftpTransfers[index].byteCount + } + } + + /// Clears terminal transfer records while leaving queued and running work + /// untouched. The transfer center is a current-session history, not a log. + public func clearFinishedSFTPTransfers() { + sftpTransfers.removeAll { + switch $0.state { + case .completed, .cancelled, .failed: true + case .queued, .running, .paused: false + } + } + } + + /// Files one or more hosts into a group, preserving the order supplied. + /// + /// `next` is the host that should follow the moved batch; `nil` appends it. + /// Updating every host before persisting makes a multi-selection one logical + /// drag instead of a series of partially saved moves. + public func move(hostIDs: [Host.ID], into group: HostGroup.ID?, before next: Host.ID? = nil) { + if let group, self.group(group) == nil { return } + guard + let placement = manualOrderPlacement( + hostIDs, before: next, + destinationItems: hosts(in: group), + validIDs: Set(hosts.map(\.id)), + sortIndex: \Host.sortIndex + ) + else { return } + if placement.moving.allSatisfy({ host($0)?.groupID == group }) + && hosts(in: group).map(\.id) == placement.resulting + { + return + } + + let movingSet = Set(placement.moving) + for index in hosts.indices { + if movingSet.contains(hosts[index].id) { hosts[index].groupID = group } + if let value = placement.positions[hosts[index].id] { + hosts[index].sortIndex = value + } + } + persistCore() + } + + @discardableResult + public func saveKnownHost(_ entry: KnownHostEntry) -> Bool { + var entry = entry + entry.hostname = entry.hostname.trimmingCharacters(in: .whitespacesAndNewlines) + entry.fingerprint = entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) + guard entry.isValid, + !knownHosts.contains(where: { + $0.id != entry.id && $0.hostname == entry.hostname && $0.port == entry.port + && $0.identity == entry.identity + }) + else { + storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.") + return false + } + let previous = knownHosts + if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) { + // Editing the address or key explicitly creates a new trust decision. + if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname + || knownHosts[index].port != entry.port + { + entry.addedAt = Date() + entry.isMismatched = false + } + knownHosts[index] = entry + } else { + entry.addedAt = Date() + knownHosts.append(entry) + } + guard persistCore() else { + knownHosts = previous + return false + } + return true + } + + @discardableResult + public func deleteKnownHosts(_ ids: Set) -> Bool { + let previous = knownHosts + knownHosts.removeAll { ids.contains($0.id) } + guard persistCore() else { + knownHosts = previous + return false + } + return true + } + + /// New records only. Existing credentials and host configurations are never overwritten. + public func commitSSHImport(_ batch: SSHImportBatch) throws { + let oldHosts = hosts + let oldIdentities = identities + let oldKeys = keys + let oldKnownHosts = knownHosts + guard Set(batch.hosts.map(\.id)).isDisjoint(with: hosts.map(\.id)), + Set(batch.keys.map(\.id)).isDisjoint(with: keys.map(\.id)), + Set(batch.identities.map(\.id)).isDisjoint(with: identities.map(\.id)), + batch.knownHosts.allSatisfy(\.isValid) + else { throw CocoaError(.coderInvalidValue) } + var written: [SSHKeyRef] = [] + do { + for key in batch.keys { + guard let secret = batch.credentials[key.id] else { + throw CocoaError(.coderValueNotFound) + } + try keychain.setKeyCredential(secret, for: key.credentialRef, storage: key.storage) + written.append(key) + } + for var key in batch.keys { + key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1 + keys.append(key) + } + for var identity in batch.identities { + identity.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1 + identities.append(identity) + } + for var host in batch.hosts { + host.sortIndex = nextSortIndex(in: nil) + hosts.append(host) + } + guard batch.hosts.allSatisfy({ validatedRoute(for: $0) != nil }) else { + throw CocoaError(.coderInvalidValue) + } + knownHosts.append(contentsOf: batch.knownHosts) + guard persistCore() else { + throw TermindSSHError.library(storageError ?? String(localized: "Import failed.")) + } + } catch { + hosts = oldHosts + identities = oldIdentities + keys = oldKeys + knownHosts = oldKnownHosts + var cleanupFailed = false + for key in written { + do { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } catch { + cleanupFailed = true + } + } + if cleanupFailed { + storageError = String( + localized: "Import failed. Some newly stored keys could not be removed from Keychain.") + throw TermindSSHError.library(storageError!) + } + throw error + } + } + + private func nextSortIndex(in group: HostGroup.ID?) -> Double { + (hosts(in: group).last?.sortIndex ?? 0) + 1 + } + + /// The same, for a group among its siblings. Separate from the hosts version + /// because the two number different collections: a new child of Production is + /// placed after Production's last *group*, and the hosts filed directly in + /// Production have their own independent run of indices. + private func nextSortIndex(under parent: HostGroup.ID?) -> Double { + let siblings = parent.map { childGroups(of: $0) } ?? rootGroups + return (siblings.last?.sortIndex ?? 0) + 1 + } + + private func nextSnippetSortIndex(in package: SnippetPackage.ID?) -> Double { + (snippets(in: package).last?.sortIndex ?? -1) + 1 + } + + private func nextSnippetPackageSortIndex(under parent: SnippetPackage.ID?) -> Double { + let siblings = parent.map { childSnippetPackages(of: $0) } ?? rootSnippetPackages + return (siblings.last?.sortIndex ?? -1) + 1 + } + + private func expandSnippetPackageAncestors(_ packageID: SnippetPackage.ID?) { + var current = packageID + var visited: Set = [] + while let id = current, let index = snippetPackages.firstIndex(where: { $0.id == id }), + visited.insert(id).inserted + { + snippetPackages[index].isExpanded = true + current = snippetPackages[index].parentID + } + } + + @discardableResult + private func persistCore() -> Bool { + do { + try vault.save( + VaultSnapshot( + hosts: hosts, groups: groups, identities: identities, + keys: keys, knownHosts: knownHosts, snippets: snippets, + snippetPackages: snippetPackages, portForwardings: portForwardings + )) + storageError = nil + return true + } catch { + storageError = error.localizedDescription + return false + } + } + + /// Applies a CloudKit-imported projection without writing it back as a new + /// local edit. Runtime connection and transfer state intentionally remain. + public func replacePersistedSnapshot(_ snapshot: VaultSnapshot) { + hosts = snapshot.hosts + groups = snapshot.groups + identities = normalizedManualOrder( + snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name + ) + keys = normalizedManualOrder( + snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label + ) + knownHosts = snapshot.knownHosts + snippets = normalizedSnippetOrder(snapshot.snippets) + snippetPackages = snapshot.snippetPackages + portForwardings = snapshot.portForwardings + statuses = statuses.filter { id, _ in hosts.contains { $0.id == id } } + connectionStatuses = connectionStatuses.filter { _, value in + hosts.contains { $0.id == value.hostID } + } + storageError = nil + vault.didApplyRemoteSnapshot() + } +} + +extension Host { + fileprivate var sshConnectionFields: SSHConnectionFields { + SSHConnectionFields( + host: host, port: port, identityID: identityID, + jumpHostID: jumpHostID, proxy: proxy + ) + } +} + +private struct SSHConnectionFields: Equatable { + let host: String + let port: Int + let identityID: Identity.ID? + let jumpHostID: Host.ID? + let proxy: HostProxy? +} + +// MARK: - Manual ordering + +/// A deterministic tie-break keeps hand-built preview data usable before its +/// first move. Persisted data normally has unique +/// fractional indices, so the name is not otherwise part of the ordering. +private func manuallyOrdered( + _ lhsIndex: Double, _ lhsName: String, + _ rhsIndex: Double, _ rhsName: String +) -> Bool { + if lhsIndex != rhsIndex { return lhsIndex < rhsIndex } + return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending +} + +/// Normalize duplicate preview and unit-test defaults once so their first +/// fractional move has real alphabetical neighbours. +private func normalizedManualOrder( + _ values: [Item], + sortIndex: WritableKeyPath, + name: KeyPath +) -> [Item] { + let indices = values.map { $0[keyPath: sortIndex] } + guard indices.contains(where: { !$0.isFinite }) || Set(indices).count != indices.count else { + return values + } + + var ordered = values.sorted { + manuallyOrdered( + $0[keyPath: sortIndex], $0[keyPath: name], + $1[keyPath: sortIndex], $1[keyPath: name] + ) + } + for index in ordered.indices { ordered[index][keyPath: sortIndex] = Double(index) } + return ordered +} + +/// Snippet indices are local to a Package. Equal indices in two different +/// Packages are expected, so normalization must never compare them globally. +private func normalizedSnippetOrder(_ values: [Snippet]) -> [Snippet] { + var result = values + for packageID in Set(values.map(\.packageID)) { + let members = result.indices.filter { result[$0].packageID == packageID } + let sortIndices = members.map { result[$0].sortIndex } + guard + sortIndices.contains(where: { !$0.isFinite }) + || Set(sortIndices).count != sortIndices.count + else { continue } + + let ordered = members.sorted { + manuallyOrdered( + result[$0].sortIndex, result[$0].title, + result[$1].sortIndex, result[$1].title + ) + } + for (position, index) in ordered.enumerated() { + result[index].sortIndex = Double(position) + } + } + return result +} + +private struct ManualOrderPlacement { + let moving: [ID] + let resulting: [ID] + let positions: [ID: Double] +} + +/// Generic remove-then-insert calculation shared by Hosts, Keys and Identities. +/// The destination may omit moving IDs (Hosts crossing groups), so validity is +/// supplied separately from the concrete destination collection. +private func manualOrderPlacement( + _ requestedIDs: [Item.ID], + before next: Item.ID?, + destinationItems: [Item], + validIDs: Set, + sortIndex: KeyPath +) -> ManualOrderPlacement? where Item.ID: Hashable { + var seen: Set = [] + let moving = requestedIDs.filter { + validIDs.contains($0) && seen.insert($0).inserted + } + guard !moving.isEmpty else { return nil } + + let movingSet = Set(moving) + let remaining = destinationItems.filter { !movingSet.contains($0.id) } + let insertionIndex: Int + if let next { + guard let index = remaining.firstIndex(where: { $0.id == next }) else { return nil } + insertionIndex = index + } else { + insertionIndex = remaining.endIndex + } + + let resulting = + Array(remaining[.. 0 ? remaining[insertionIndex - 1][keyPath: sortIndex] : nil + let upper = insertionIndex < remaining.endIndex ? remaining[insertionIndex][keyPath: sortIndex] : nil + var positions: [Item.ID: Double] = [:] + + switch (lower, upper) { + case (let lower?, let upper?): + let step = (upper - lower) / Double(moving.count + 1) + if step.isFinite && step > 0 { + for (offset, id) in moving.enumerated() { + positions[id] = lower + step * Double(offset + 1) + } + } + case (let lower?, nil): + for (offset, id) in moving.enumerated() { + positions[id] = lower + Double(offset + 1) + } + case (nil, let upper?): + for (offset, id) in moving.enumerated() { + positions[id] = upper - Double(moving.count - offset) + } + case (nil, nil): + for (offset, id) in moving.enumerated() { positions[id] = Double(offset) } + } + + if positions.count != moving.count { + positions = Dictionary( + uniqueKeysWithValues: resulting.enumerated().map { ($0.element, Double($0.offset)) } + ) + } + return ManualOrderPlacement(moving: moving, resulting: resulting, positions: positions) +} + +/// Shared fixed-section wrapper for every manually ordered Keychain collection. +private func reorderManually( + _ requestedIDs: [Item.ID], + before next: Item.ID?, + items: inout [Item], + sortIndex: WritableKeyPath +) -> Bool where Item.ID: Hashable { + let current = items.sorted { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] } + guard + let placement = manualOrderPlacement( + requestedIDs, before: next, destinationItems: current, + validIDs: Set(current.map(\.id)), sortIndex: sortIndex + ), placement.resulting != current.map(\.id) + else { return false } + + for index in items.indices { + if let value = placement.positions[items[index].id] { + items[index][keyPath: sortIndex] = value + } + } + items.sort { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] } + return true +} diff --git a/Frameworks/TermindApplication/FeatureModels/KnownHostsLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/KnownHostsLibraryModel.swift new file mode 100644 index 00000000..58185394 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/KnownHostsLibraryModel.swift @@ -0,0 +1,20 @@ +import Foundation +import TermindDomain + +@Observable +public final class KnownHostsLibraryModel { + private let library: HostLibraryModel + public init(library: HostLibraryModel) { self.library = library } + public var entries: [KnownHostEntry] { library.knownHosts.sorted { $0.addedAt > $1.addedAt } } + public var storageError: String? { library.storageError } + public func entry(_ id: UUID) -> KnownHostEntry? { entries.first { $0.id == id } } + public func search(_ query: String) -> [KnownHostEntry] { + entries.filter { + query.isEmpty || $0.displayAddress.localizedCaseInsensitiveContains(query) + || $0.fingerprint.localizedCaseInsensitiveContains(query) + || $0.algorithm.rawValue.localizedCaseInsensitiveContains(query) + } + } + public func save(_ entry: KnownHostEntry) -> Bool { library.saveKnownHost(entry) } + public func delete(_ ids: Set) -> Bool { library.deleteKnownHosts(ids) } +} diff --git a/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift new file mode 100644 index 00000000..cf1173d7 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/SSHImportModel.swift @@ -0,0 +1,406 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +@Observable +public final class SSHImportModel { + public var items: [SSHImportItem] = [] + public var expanded = Set(SSHImportCategory.allCases) + public var storage: KeyStorage = .iCloud + public private(set) var isWorking = false + public private(set) var error: String? + public private(set) var completed = false + public private(set) var importedCount = 0 + private let library: HostLibraryModel + private let reader: any SSHImportReading + private var root: URL? + private var grants: [URL] = [] + public var sourcePath: String? { root?.path } + private var knownHostsOnly = false + private let defaultUsername: String + private var jumpIDs: [String: UUID] = [:] + + public init(library: HostLibraryModel, reader: any SSHImportReading, defaultUsername: String = "") { + self.defaultUsername = defaultUsername + self.library = library + self.reader = reader + } + + public func scan(_ url: URL, knownHostsOnly: Bool) async { + guard !isWorking else { return } + grants = [] + items = [] + completed = false + root = url + self.knownHostsOnly = knownHostsOnly + await reload() + } + + public func authorize(_ url: URL) async { + grants.append(url) + await reload() + } + + private func reload() async { + guard let root else { return } + isWorking = true + error = nil + defer { isWorking = false } + do { + let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) + items = try await reader.scan(root: root, knownHostsOnly: knownHostsOnly, grants: grants) + addJumpDestinations() + for index in items.indices { + if let old = previous[items[index].id] { + items[index].selected = old.selected + items[index].acceptedWarnings = old.acceptedWarnings + items[index].preferredKeyID = old.preferredKeyID + if items[index].host != nil { items[index].name = old.name } + } + } + refreshDuplicates() + } catch { + self.error = + (error as? SSHImportSourceError)?.localizedDescription + ?? String(localized: "The SSH files could not be read.") + } + } + + private func addJumpDestinations() { + let originalIDs = items.filter { $0.host != nil }.map(\.id) + let jumps = items.compactMap(\.host).flatMap(\.jumps) + for jump in jumps where !items.contains(where: { $0.host?.alias == jump }) { + let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init) + let address = userParts.last ?? jump + let host: String + let port: Int? + if address.hasPrefix("["), let endpoint = SSHImportParser.endpoint(address) { + (host, port) = endpoint + } else if address.filter({ $0 == ":" }).count == 1 { + let parts = address.split(separator: ":").map(String.init) + host = parts.first ?? address + port = parts.count == 2 ? Int(parts[1]) ?? 0 : 0 + } else { + host = address + port = nil + } + let saved = library.hosts.first { $0.name == host } + let identity = saved?.identityID.flatMap(library.identity) + let base = items.first { $0.host?.alias == host }?.host + let value = SSHImportedHost( + alias: jump, hostname: base?.hostname ?? saved?.host ?? host, + port: port ?? base?.port ?? saved?.port ?? 22, + username: userParts.count == 2 ? userParts[0] : base?.username ?? identity?.username ?? defaultUsername, + identityPaths: base?.identityPaths ?? [], existingKeyID: identity?.keyID) + let id = jumpIDs[jump] ?? UUID() + jumpIDs[jump] = id + items.append(SSHImportItem(id: id, category: .hosts, name: jump, source: "ProxyJump", host: value)) + } + // A multi-hop route must not rewrite the standalone configuration of its intermediate aliases. + for id in originalIDs { + guard let index = items.firstIndex(where: { $0.id == id }), + let chain = items[index].host?.jumps, chain.count > 1 + else { continue } + var previous = chain[0] + for alias in chain.dropFirst() { + guard let base = items.first(where: { $0.host?.alias == alias }), var host = base.host else { continue } + let routeAlias = previous + " → " + alias + if !items.contains(where: { $0.host?.alias == routeAlias }) { + host.alias = routeAlias + host.jumps = [previous] + let id = jumpIDs[routeAlias] ?? UUID() + jumpIDs[routeAlias] = id + items.append( + SSHImportItem( + id: id, category: .hosts, name: routeAlias, + source: items[index].source, warnings: base.warnings, host: host)) + } + previous = routeAlias + } + items[index].host?.jumps = [previous] + } + } + + public func refreshDuplicates() { + for index in items.indices { + guard !items[index].unavailable else { continue } + if let key = items[index].key { + if let existing = library.keys.first(where: { + $0.fingerprint == key.fingerprint && $0.certificate == key.certificate + && library.keyAvailability(for: $0) == .available + }) { + items[index].existingID = existing.id + items[index].selected = false + } else if let duplicate = items[.. Bool = { + $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) + && $0.identity == entry.identity + } + if let existing = library.knownHosts.first(where: same) { + items[index].existingID = existing.id + items[index].selected = false + } else if let previous = items[.. Bool { !item.unavailable && item.existingID == nil } + public func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] { + items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected } + } + public func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] { + items.filter { (category == nil || $0.category == category) && selectable($0) } + } + public func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) { + for index in items.indices + where selectable(items[index]) && (category == nil || items[index].category == category) { + items[index].selected = selected + } + } + + public func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] { + guard let host = item.host else { return [] } + if let id = host.existingKeyID, let key = library.key(id) { return [key] } + var seen: Set = [] + return host.identityPaths.flatMap { path in + items.filter { + guard $0.source == path && $0.category == .keys else { return false } + if host.certificatePaths.isEmpty { + return $0.certificatePaths.isEmpty || $0.certificatePaths.contains(path + "-cert.pub") + } + return !$0.certificatePaths.isEmpty && !Set($0.certificatePaths).isDisjoint(with: host.certificatePaths) + }.compactMap { candidate -> SSHKeyRef? in + if let id = candidate.existingID { + if let key = library.key(id) { return key } + if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key } + return nil + } + return candidate.selected ? candidate.key : nil + } + }.filter { seen.insert($0.id).inserted } + } + + public func chosenKey(for item: SSHImportItem) -> SSHKeyRef? { + let options = keyOptions(for: item) + return options.first { $0.id == item.preferredKeyID } ?? options.first + } + + private func identicalHost(_ item: SSHImportItem) -> Host? { + func matches(_ row: SSHImportItem, _ saved: Host, visited: Set) -> Bool { + guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host, + saved.name == row.name && saved.host == candidate.hostname && saved.port == candidate.port, + let identity = saved.identityID.flatMap(library.identity), identity.username == candidate.username, + identity.keyID == chosenKey(for: row)?.id + else { return false } + if candidate.jumps.isEmpty { return saved.jumpHostID == nil } + guard candidate.jumps.count == 1, let alias = candidate.jumps.first, + let jump = saved.jumpHostID.flatMap(library.host) + else { return false } + if let next = items.first(where: { $0.host?.alias == alias }) { + return matches(next, jump, visited: visited.union([saved.id])) + } + return jump.name == alias + } + return library.hosts.first { matches(item, $0, visited: []) } + } + + public func warnings(for item: SSHImportItem) -> [String] { + var warnings = item.warnings + if let entry = item.knownHost, + library.knownHosts.contains(where: { + let sameAddress = + $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) + || (!entry.isHashed && $0.matches(hostname: entry.hostname, port: entry.port)) + || (!$0.isHashed && entry.matches(hostname: $0.hostname, port: $0.port)) + return sameAddress && $0.identity != entry.identity + }) + { + warnings.append( + String(localized: "This address already has a different trusted fingerprint. Confirm adding this key.")) + } + return warnings + } + + public func hasNameConflict(_ item: SSHImportItem) -> Bool { + guard item.host != nil else { return false } + return (library.hosts.contains { $0.name == item.name } && identicalHost(item) == nil) + || selectedItems(.hosts).contains { $0.id != item.id && $0.name == item.name } + } + + public func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? { + if !item.issue.isEmpty { return item.issue } + if item.requiresPassphrase { + return String(localized: "Enter the key passphrase to validate this private key.") + } + if includeWarningReminder && !warnings(for: item).isEmpty && !item.acceptedWarnings { + return String(localized: "Review and accept the import warnings.") + } + if let host = item.host { + guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !host.hostname.isEmpty, !host.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + (1...65_535).contains(host.port) + else { return String(localized: "Invalid SSH record.") } + if hasNameConflict(item) { + return String(localized: "A different host already uses this name. Rename this import or deselect it.") + } + for jump in host.jumps { + if !library.hosts.contains(where: { $0.name == jump }) + && !items.contains(where: { $0.host?.alias == jump && ($0.selected || $0.existingID != nil) }) + { + return String(localized: "Select the required jump host or deselect this host.") + } + } + } + return nil + } + + public var canImport: Bool { + !isWorking && !completed && !selectedItems().isEmpty + && selectedItems().allSatisfy { problem(for: $0) == nil } + && (try? makeBatch()) != nil + } + + public func unlock(_ id: UUID, passphrase: String) async { + guard let index = items.firstIndex(where: { $0.id == id }) else { return } + isWorking = true + defer { isWorking = false } + do { + items[index].key = try await reader.unlock(id: id, name: items[index].name, passphrase: passphrase) + items[index].requiresPassphrase = false + items[index].issue = "" + refreshDuplicates() + } catch { + items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.") + } + } + + public func commit() async { + refreshDuplicates() + guard canImport else { return } + isWorking = true + error = nil + defer { isWorking = false } + do { + var batch = try makeBatch() + batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id))) + // Revalidate after awaiting the reader, because another window or sync can write meanwhile. + refreshDuplicates() + let refreshed = try makeBatch() + guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)), + selectedItems().allSatisfy({ problem(for: $0) == nil }) + else { + throw CocoaError(.coderInvalidValue) + } + batch.hosts = refreshed.hosts + batch.identities = refreshed.identities + batch.knownHosts = refreshed.knownHosts + try library.commitSSHImport(batch) + importedCount = batch.keys.count + batch.hosts.count + batch.knownHosts.count + completed = true + await reader.discard() + } catch { self.error = error.localizedDescription } + } + + public func discard() async { await reader.discard() } + + private func makeBatch() throws -> SSHImportBatch { + var batch = SSHImportBatch() + batch.keys = selectedItems(.keys).compactMap { item in + guard var key = item.key else { return nil } + key.storage = storage + return key + } + batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost) + var built: [UUID: Host] = [:] + var visiting: Set = [] + func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host { + if let existing = item.existingID.flatMap(library.host) { return existing } + if let existing = built[item.id] { + if let forcedJump, existing.jumpHostID != forcedJump { throw ImportRouteError.conflictingChain } + return existing + } + guard visiting.insert(item.id).inserted, visiting.count <= 8, let imported = item.host else { + throw ImportRouteError.invalid + } + defer { visiting.remove(item.id) } + var jumpID = forcedJump + for alias in imported.jumps { + if let candidate = items.first(where: { + $0.host?.alias == alias && ($0.selected || $0.existingID != nil) + }) { + jumpID = try build(candidate, forcedJump: jumpID).id + } else if let existing = library.hosts.first(where: { $0.name == alias }) { + if let jumpID, existing.jumpHostID != jumpID { throw ImportRouteError.conflictingChain } + jumpID = existing.id + } else { + throw ImportRouteError.invalid + } + } + let keyID = chosenKey(for: item)?.id + let identity: Identity + if let existing = (library.identities + batch.identities).first(where: { + $0.username == imported.username && $0.keyID == keyID + }) { + identity = existing + } else { + identity = Identity(name: imported.username, username: imported.username, keyID: keyID) + batch.identities.append(identity) + } + let host = Host( + id: item.id, name: item.name, host: imported.hostname, port: imported.port, + identityID: identity.id, jumpHostID: jumpID) + built[item.id] = host + return host + } + // Build routes with the most hops first so intermediate aliases get their chain before reuse. + for item in selectedItems(.hosts).sorted(by: { ($0.host?.jumps.count ?? 0) > ($1.host?.jumps.count ?? 0) }) { + _ = try build(item) + } + batch.hosts = built.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + let all = Dictionary(uniqueKeysWithValues: (library.hosts + batch.hosts).map { ($0.id, $0) }) + for host in batch.hosts { + var current: Host? = host + var seen: Set = [] + while let node = current { + guard seen.insert(node.id).inserted, seen.count <= 8 else { throw ImportRouteError.invalid } + current = node.jumpHostID.flatMap { all[$0] } + } + } + return batch + } + + public var routeProblem: String? { + do { + _ = try makeBatch() + return nil + } catch { return error.localizedDescription } + } +} + +nonisolated private enum ImportRouteError: LocalizedError { + case invalid, conflictingChain + public var errorDescription: String? { + switch self { + case .invalid: String(localized: "The imported jump route is missing, cyclic, or exceeds 8 hops.") + case .conflictingChain: + String(localized: "These hosts require conflicting jump routes. Import the routes separately.") + } + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift new file mode 100644 index 00000000..44940901 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/SnippetLibraryModel.swift @@ -0,0 +1,56 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// The Snippets feature's deliberately narrow view of the shared vault. +@Observable +public final class SnippetLibraryModel { + private let library: HostLibraryModel + + public init(library: HostLibraryModel) { + self.library = library + } + + public var hosts: [Host] { library.hosts } + public var snippets: [Snippet] { library.snippets } + public var snippetPackages: [SnippetPackage] { library.snippetPackages } + public var rootSnippetPackages: [SnippetPackage] { library.rootSnippetPackages } + public var isSnippetsUngroupedExpanded: Bool { library.isSnippetsUngroupedExpanded } + + public func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? { + library.snippetPackage(id) + } + public func childSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { + library.childSnippetPackages(of: parent) + } + public func descendantSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { + library.descendantSnippetPackages(of: parent) + } + public func snippets(in package: SnippetPackage.ID?) -> [Snippet] { library.snippets(in: package) } + public func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? { + library.snippetPackagePath(packageID) + } + public func searchSnippets(_ query: String) -> [Snippet] { library.searchSnippets(query) } + public func searchHosts(_ query: String) -> [Host] { library.searchHosts(query) } + public func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } + + public func setSnippetPackageExpanded(_ expanded: Bool, for packageID: SnippetPackage.ID) { + library.setSnippetPackageExpanded(expanded, for: packageID) + } + public func setSnippetsUngroupedExpanded(_ expanded: Bool) { + library.setSnippetsUngroupedExpanded(expanded) + } + public func upsert(_ snippet: Snippet) { library.upsert(snippet) } + public func upsert(_ package: SnippetPackage) { library.upsert(package) } + public func delete(snippetIDs: [Snippet.ID]) { library.delete(snippetIDs: snippetIDs) } + public func delete(snippetPackageID: SnippetPackage.ID, keepSnippets: Bool) { + library.delete(snippetPackageID: snippetPackageID, keepSnippets: keepSnippets) + } + public func move( + snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?, + before next: Snippet.ID? = nil + ) { + library.move(snippetIDs: snippetIDs, into: packageID, before: next) + } +} diff --git a/Frameworks/TermindApplication/MetricsConfigurationStoring.swift b/Frameworks/TermindApplication/MetricsConfigurationStoring.swift new file mode 100644 index 00000000..ae113573 --- /dev/null +++ b/Frameworks/TermindApplication/MetricsConfigurationStoring.swift @@ -0,0 +1,7 @@ +import TermindDomain + +@MainActor +public protocol MetricsConfigurationStoring { + func load() -> [MetricsSelection] + func save(_ selections: [MetricsSelection]) +} diff --git a/Frameworks/TermindApplication/MetricsSampling.swift b/Frameworks/TermindApplication/MetricsSampling.swift new file mode 100644 index 00000000..70de413d --- /dev/null +++ b/Frameworks/TermindApplication/MetricsSampling.swift @@ -0,0 +1,15 @@ +import TermindDomain + +public nonisolated protocol MetricsSampling: Sendable { + func connect(_ configuration: SSHConnectionConfiguration) async throws + func sample() async throws -> MetricsSample + func filesystems() async throws -> [MetricsFilesystem]? + func processes() async throws -> MetricsProcessSample? + func gpu() async throws -> NvidiaSnapshot? + func disconnect() async +} + +nonisolated extension MetricsSampling { + public func filesystems() async throws -> [MetricsFilesystem]? { nil } + public func processes() async throws -> MetricsProcessSample? { nil } +} diff --git a/Frameworks/TermindApplication/Repositories/SSHImportBatch.swift b/Frameworks/TermindApplication/Repositories/SSHImportBatch.swift new file mode 100644 index 00000000..99051e24 --- /dev/null +++ b/Frameworks/TermindApplication/Repositories/SSHImportBatch.swift @@ -0,0 +1,24 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +/// Execution-only payload. Secrets are never part of the observable import preview. +public nonisolated struct SSHImportBatch: Sendable { + public var hosts: [Host] = [] + public var identities: [Identity] = [] + public var keys: [SSHKeyRef] = [] + public var knownHosts: [KnownHostEntry] = [] + public var credentials: [UUID: StoredSSHKeyCredential] = [:] + public init( + hosts: [Host] = [], identities: [Identity] = [], keys: [SSHKeyRef] = [], knownHosts: [KnownHostEntry] = [], + credentials: [UUID: StoredSSHKeyCredential] = [:] + ) { + self.hosts = hosts + self.identities = identities + self.keys = keys + self.knownHosts = knownHosts + self.credentials = credentials + } + +} diff --git a/Frameworks/TermindApplication/Repositories/SSHImportReading.swift b/Frameworks/TermindApplication/Repositories/SSHImportReading.swift new file mode 100644 index 00000000..70eb7bcd --- /dev/null +++ b/Frameworks/TermindApplication/Repositories/SSHImportReading.swift @@ -0,0 +1,9 @@ +import Foundation +import TermindDomain + +public nonisolated protocol SSHImportReading: Sendable { + func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) async throws -> [SSHImportItem] + func unlock(id: UUID, name: String, passphrase: String) async throws -> SSHKeyRef + func credentials(for ids: Set) async throws -> [UUID: StoredSSHKeyCredential] + func discard() async +} diff --git a/Frameworks/TermindApplication/Repositories/VaultStore.swift b/Frameworks/TermindApplication/Repositories/VaultStore.swift new file mode 100644 index 00000000..f6393ca4 --- /dev/null +++ b/Frameworks/TermindApplication/Repositories/VaultStore.swift @@ -0,0 +1,34 @@ +import Foundation +import TermindDomain + +/// Persistence boundary shared by the Vault feature models. It keeps concrete +/// storage and sync notifications out of aggregate models while preserving the +/// existing `VaultSnapshot` transaction and on-disk schema. +@MainActor +public final class VaultStore { + private let persistence: (any VaultPersisting)? + public var didPersist: (() -> Void)? + public var didRecordChanges: ((VaultMutationSet) -> Void)? + + public init(persistence: (any VaultPersisting)? = nil) { + self.persistence = persistence + } + + public func load(fallback: VaultSnapshot) throws -> VaultSnapshot { + try persistence?.load() ?? fallback + } + + public func save(_ snapshot: VaultSnapshot) throws { + if let persistence { + let mutations = try persistence.save(snapshot) + didRecordChanges?(mutations) + } + didPersist?() + } + + /// A remote projection is already persisted by the sync engine. Aggregate + /// models only need to refresh repository projections after accepting it. + public func didApplyRemoteSnapshot() { + didPersist?() + } +} diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift new file mode 100644 index 00000000..a940bc1c --- /dev/null +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -0,0 +1,187 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated struct SSHHostKeyObservation: Hashable, Sendable { + public var hostname: String + public var port: Int + public var algorithm: SSHKeyAlgorithm + public var fingerprint: String + + public var identity: SSHHostKeyIdentity { + SSHHostKeyIdentity(algorithm: algorithm, fingerprint: fingerprint) + } + public init(hostname: String, port: Int, algorithm: SSHKeyAlgorithm, fingerprint: String) { + self.hostname = hostname + self.port = port + self.algorithm = algorithm + self.fingerprint = fingerprint + } + +} + +public nonisolated struct SSHHostKeyPrompt: Hashable, Sendable { + public enum Kind: Hashable, Sendable { + case firstUse + case additionalKey(trustedKeyCount: Int) + } + + public var observation: SSHHostKeyObservation + public var kind: Kind + public init(observation: SSHHostKeyObservation, kind: Kind) { + self.observation = observation + self.kind = kind + } + +} + +public nonisolated enum SSHCredentials: Sendable { + case password(String) + case privateKey(privateKey: String, publicKey: String?, passphrase: String?) + case secureEnclave(credential: SecureEnclaveKeyCredential, publicKey: String) +} + +nonisolated extension StoredSSHKeyCredential { + public func sshCredential( + publicKey: String, certificate: String = "", passphraseOverride: String? = nil + ) -> SSHCredentials { + switch self { + case .software(let secret): + .privateKey( + privateKey: secret.privateKey, + publicKey: certificate.isEmpty + ? (publicKey.isEmpty ? nil : publicKey) + : certificate, + passphrase: passphraseOverride + ) + case .secureEnclave(let credential): + .secureEnclave(credential: credential, publicKey: publicKey) + } + } +} + +public nonisolated struct SSHConnectionConfiguration: Sendable { + public var hostID: Host.ID + public var host: String + public var port: Int + public var username: String + public var credentials: [SSHCredentials] + public var expectedHostKeys: Set + public var proxy: SSHTransportProxy? + public var jumps: [SSHRouteHopConfiguration] = [] + public init( + hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials], + expectedHostKeys: Set, proxy: SSHTransportProxy? = nil, + jumps: [SSHRouteHopConfiguration] = [] + ) { + self.hostID = hostID + self.host = host + self.port = port + self.username = username + self.credentials = credentials + self.expectedHostKeys = expectedHostKeys + self.proxy = proxy + self.jumps = jumps + } + +} + +public nonisolated struct SSHCredentialResolution: Sendable { + public var credentials: [SSHCredentials] + public var failures: [String] + public var hasConfiguredCredential: Bool + public var requiredPassphraseKeyID: SSHKeyRef.ID? + public init( + credentials: [SSHCredentials], failures: [String], hasConfiguredCredential: Bool, + requiredPassphraseKeyID: SSHKeyRef.ID? = nil + ) { + self.credentials = credentials + self.failures = failures + self.hasConfiguredCredential = hasConfiguredCredential + self.requiredPassphraseKeyID = requiredPassphraseKeyID + } + +} + +public nonisolated enum TermindSSHError: LocalizedError, Sendable { + case socket(String) + case proxy(String) + case route(String) + case routeHost( + hostID: Host.ID, authentication: Bool, serverChallenge: Bool, + hostKey: Bool, message: String + ) + case library(String) + case authenticationFailed(String) + case hostKeyChanged(expected: [String], received: String) + case missingCredential + case keyPassphraseRequired + case missingUsername + case untrustedHost + case mismatchedHostKey + case disconnected + + public var errorDescription: String? { + switch self { + case .socket(let message), .proxy(let message), .route(let message), + .library(let message), .authenticationFailed(let message): + message + case .routeHost(_, _, _, _, let message): message + case .hostKeyChanged(let expected, let received): + String( + localized: + "The host key changed. Expected one of \(expected.joined(separator: ", ")), received \(received).") + case .missingCredential: String(localized: "The selected identity has no usable password or private key.") + case .keyPassphraseRequired: String(localized: "Enter the Passphrase for the selected key.") + case .missingUsername: String(localized: "The selected host has no login username.") + case .untrustedHost: String(localized: "Connect to this host once before exporting a key to it.") + case .mismatchedHostKey: String(localized: "The saved host key no longer matches this host.") + case .disconnected: String(localized: "The SSH connection closed.") + } + } + + public var shouldRequestAuthentication: Bool { + if case .authenticationFailed = self { return true } + if case .routeHost(_, let authentication, _, _, _) = self { return authentication } + return false + } + + public var isLocalAuthenticationRequirement: Bool { + switch self { + case .missingCredential, .keyPassphraseRequired, .missingUsername: true + default: false + } + } + + public var isServerAuthenticationChallenge: Bool { + if case .authenticationFailed = self { return true } + if case .routeHost(_, _, let serverChallenge, _, _) = self { return serverChallenge } + return false + } + + public var routeHostID: Host.ID? { + guard case .routeHost(let id, _, _, _, _) = self else { return nil } + return id + } + + public var shouldRequestHostKey: Bool { + guard case .routeHost(_, _, _, let hostKey, _) = self else { return false } + return hostKey + } + + public var isHostKeyChanged: Bool { + if case .hostKeyChanged = self { return true } + return false + } + + public var isRetryableForwardingFailure: Bool { + switch self { + case .socket, .library, .disconnected: true + case .proxy, .route, .routeHost, .authenticationFailed, .hostKeyChanged, + .missingCredential, .keyPassphraseRequired, .missingUsername, + .untrustedHost, .mismatchedHostKey: + false + } + } +} diff --git a/Frameworks/TermindApplication/SSHRouteEvent.swift b/Frameworks/TermindApplication/SSHRouteEvent.swift new file mode 100644 index 00000000..9721781b --- /dev/null +++ b/Frameworks/TermindApplication/SSHRouteEvent.swift @@ -0,0 +1,33 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated enum SSHRouteEvent: Hashable, Sendable { + case connecting(Host.ID) + case authenticating(Host.ID) + case authenticated(Host.ID) + case connected(Host.ID) + case initializing(Host.ID, String) +} + +public typealias SSHRouteProgressHandler = @Sendable (SSHRouteEvent) -> Void + +public nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { + public var username: String + public var password: String? + public var passphrase: String? + public var keyID: SSHKeyRef.ID? + public var identityID: Identity.ID? = nil + public init( + username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil, + identityID: Identity.ID? = nil + ) { + self.username = username + self.password = password + self.passphrase = passphrase + self.keyID = keyID + self.identityID = identityID + } + +} diff --git a/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift b/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift new file mode 100644 index 00000000..04294ba5 --- /dev/null +++ b/Frameworks/TermindApplication/SSHRouteHopConfiguration.swift @@ -0,0 +1,29 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated struct SSHRouteHopConfiguration: Sendable { + public var id: Host.ID + public var name: String + public var host: String + public var port: Int + public var username: String + public var credentials: [SSHCredentials] + public var expectedHostKeys: Set + public var proxy: SSHTransportProxy? + public init( + id: Host.ID, name: String, host: String, port: Int, username: String, credentials: [SSHCredentials], + expectedHostKeys: Set, proxy: SSHTransportProxy? = nil + ) { + self.id = id + self.name = name + self.host = host + self.port = port + self.username = username + self.credentials = credentials + self.expectedHostKeys = expectedHostKeys + self.proxy = proxy + } + +} diff --git a/Frameworks/TermindApplication/SSHTransportProxy.swift b/Frameworks/TermindApplication/SSHTransportProxy.swift new file mode 100644 index 00000000..13790ffd --- /dev/null +++ b/Frameworks/TermindApplication/SSHTransportProxy.swift @@ -0,0 +1,20 @@ +import Foundation +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated struct SSHTransportProxy: Sendable { + public var kind: HostProxy.ProtocolKind + public var hostname: String + public var port: Int + public var username: String + public var password: String? + public init(kind: HostProxy.ProtocolKind, hostname: String, port: Int, username: String, password: String? = nil) { + self.kind = kind + self.hostname = hostname + self.port = port + self.username = username + self.password = password + } + +} diff --git a/Frameworks/TermindApplication/SecretStoring.swift b/Frameworks/TermindApplication/SecretStoring.swift new file mode 100644 index 00000000..6f5234d1 --- /dev/null +++ b/Frameworks/TermindApplication/SecretStoring.swift @@ -0,0 +1,59 @@ +import Foundation +import TermindDomain + +public nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable { + public var privateKey: String + public init(privateKey: String) { + self.privateKey = privateKey + } + +} + +public nonisolated protocol SecretStoring: Sendable { + func string(for account: String, storage: PasswordStorage) throws -> String? + func setString(_ value: String?, for account: String, storage: PasswordStorage) throws + func keyCredential( + for account: String, storage: KeyStorage + ) throws -> StoredSSHKeyCredential? + func setKeyCredential( + _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage + ) throws + func removePassword(_ account: String, storage: PasswordStorage) throws + func removeKeyCredential(_ account: String, storage: KeyStorage) throws +} + +public nonisolated final class InMemorySecretStore: SecretStoring, @unchecked Sendable { + public init() {} + + private let lock = NSLock() + private var strings: [String: String] = [:] + private var keys: [String: StoredSSHKeyCredential] = [:] + + public func string(for account: String, storage: PasswordStorage) throws -> String? { + lock.withLock { strings[account] } + } + + public func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { + lock.withLock { strings[account] = value } + } + + public func keyCredential( + for account: String, storage: KeyStorage + ) throws -> StoredSSHKeyCredential? { + lock.withLock { keys[account] } + } + + public func setKeyCredential( + _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage + ) throws { + lock.withLock { keys[account] = value } + } + + public func removePassword(_ account: String, storage: PasswordStorage) throws { + lock.withLock { strings[account] = nil } + } + + public func removeKeyCredential(_ account: String, storage: KeyStorage) throws { + lock.withLock { keys[account] = nil } + } +} diff --git a/Frameworks/TermindApplication/StoredSSHKeyCredential.swift b/Frameworks/TermindApplication/StoredSSHKeyCredential.swift new file mode 100644 index 00000000..233a0dac --- /dev/null +++ b/Frameworks/TermindApplication/StoredSSHKeyCredential.swift @@ -0,0 +1,26 @@ +import Foundation + +public nonisolated struct SecureEnclaveKeyCredential: Codable, Hashable, Sendable { + public var dataRepresentation: Data + public init(dataRepresentation: Data) { + self.dataRepresentation = dataRepresentation + } + +} + +public nonisolated enum StoredSSHKeyCredential: Hashable, Sendable { + case software(SSHPrivateKeySecret) + case secureEnclave(SecureEnclaveKeyCredential) +} + +public nonisolated struct SecureEnclaveKeyMaterial: Sendable { + public var credential: SecureEnclaveKeyCredential + public var publicKey: String + public var fingerprint: String + public init(credential: SecureEnclaveKeyCredential, publicKey: String, fingerprint: String) { + self.credential = credential + self.publicKey = publicKey + self.fingerprint = fingerprint + } + +} diff --git a/Frameworks/TermindApplication/VaultPersisting.swift b/Frameworks/TermindApplication/VaultPersisting.swift new file mode 100644 index 00000000..e236d858 --- /dev/null +++ b/Frameworks/TermindApplication/VaultPersisting.swift @@ -0,0 +1,8 @@ +import TermindDomain + +@MainActor +public protocol VaultPersisting: AnyObject { + func load() throws -> VaultSnapshot + func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet + func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult +} diff --git a/Frameworks/TermindDomain/Credentials/SSHUserCertificate.swift b/Frameworks/TermindDomain/Credentials/SSHUserCertificate.swift new file mode 100644 index 00000000..ab70469c --- /dev/null +++ b/Frameworks/TermindDomain/Credentials/SSHUserCertificate.swift @@ -0,0 +1,180 @@ +import Foundation + +public nonisolated enum SSHUserCertificateError: LocalizedError, Equatable { + case invalidFormat + case unsupportedAlgorithm(String) + case hostCertificate + case keyMismatch + + public var errorDescription: String? { + switch self { + case .invalidFormat: + String(localized: "The certificate is not a valid OpenSSH user certificate.") + case .unsupportedAlgorithm(let algorithm): + String(localized: "Unsupported SSH certificate algorithm: \(algorithm)") + case .hostCertificate: + String(localized: "Host certificates cannot be used for user authentication.") + case .keyMismatch: + String(localized: "The certificate does not belong to this private key.") + } + } +} + +public nonisolated struct SSHUserCertificate: Hashable, Sendable { + public var authorizedKey: String + public var algorithm: SSHKeyAlgorithm + public var publicKeyBlob: Data + public var serial: UInt64 + public var keyID: String + public var validAfter: Date + public var validBefore: Date? + + public var validityWarning: String? { + let now = Date() + if now < validAfter { + return String(localized: "This certificate is not valid yet.") + } + if let validBefore, now >= validBefore { + return String(localized: "This certificate has expired.") + } + return nil + } + + public static func parse(_ text: String, matching publicKey: String? = nil) throws -> Self { + let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) + let fields = normalized.split(whereSeparator: \Character.isWhitespace) + guard fields.count >= 2, + let blob = Data(base64Encoded: String(fields[1])) + else { throw SSHUserCertificateError.invalidFormat } + + var reader = SSHWireReader(data: blob) + guard let certificateMethodData = reader.readField(), + let certificateMethod = String(data: certificateMethodData, encoding: .utf8), + certificateMethod == String(fields[0]), + certificateMethod.hasSuffix("-cert-v01@openssh.com"), + reader.readField() != nil + else { throw SSHUserCertificateError.invalidFormat } + + let algorithm: SSHKeyAlgorithm + let plainMethod: String + let keyFields: [Data] + switch certificateMethod { + case "ssh-ed25519-cert-v01@openssh.com": + algorithm = .ed25519 + plainMethod = SSHKeyAlgorithm.ed25519.rawValue + keyFields = try reader.readFields(count: 1) + case "ecdsa-sha2-nistp256-cert-v01@openssh.com": + algorithm = .ecdsa256 + plainMethod = SSHKeyAlgorithm.ecdsa256.rawValue + keyFields = try reader.readFields(count: 2) + case "ecdsa-sha2-nistp384-cert-v01@openssh.com": + algorithm = .ecdsa384 + plainMethod = SSHKeyAlgorithm.ecdsa384.rawValue + keyFields = try reader.readFields(count: 2) + case "ecdsa-sha2-nistp521-cert-v01@openssh.com": + algorithm = .ecdsa521 + plainMethod = SSHKeyAlgorithm.ecdsa521.rawValue + keyFields = try reader.readFields(count: 2) + case "ssh-rsa-cert-v01@openssh.com", + "rsa-sha2-256-cert-v01@openssh.com", + "rsa-sha2-512-cert-v01@openssh.com": + algorithm = .rsa + plainMethod = SSHKeyAlgorithm.rsa.rawValue + keyFields = try reader.readFields(count: 2) + default: + throw SSHUserCertificateError.unsupportedAlgorithm(certificateMethod) + } + + guard let serial = reader.readUInt64(), let type = reader.readUInt32(), + let keyIDData = reader.readField(), + reader.readField() != nil, + let validAfter = reader.readUInt64(), let validBefore = reader.readUInt64(), + reader.readField() != nil, reader.readField() != nil, + reader.readField() != nil, reader.readField() != nil, + reader.readField() != nil, reader.isAtEnd + else { throw SSHUserCertificateError.invalidFormat } + guard type == 1 else { + if type == 2 { throw SSHUserCertificateError.hostCertificate } + throw SSHUserCertificateError.invalidFormat + } + + let publicBlob = + SSHWireWriter.field(Data(plainMethod.utf8)) + + keyFields.reduce(into: Data()) { $0.append(SSHWireWriter.field($1)) } + if let publicKey { + let publicFields = publicKey.split(whereSeparator: \Character.isWhitespace) + guard publicFields.count >= 2, + let expected = Data(base64Encoded: String(publicFields[1])), + expected == publicBlob + else { throw SSHUserCertificateError.keyMismatch } + } + + return Self( + authorizedKey: normalized, algorithm: algorithm, publicKeyBlob: publicBlob, + serial: serial, keyID: String(data: keyIDData, encoding: .utf8) ?? "", + validAfter: Date(timeIntervalSince1970: TimeInterval(validAfter)), + validBefore: validBefore == UInt64.max + ? nil : Date(timeIntervalSince1970: TimeInterval(validBefore)) + ) + } + public init( + authorizedKey: String, algorithm: SSHKeyAlgorithm, publicKeyBlob: Data, serial: UInt64, keyID: String, + validAfter: Date, validBefore: Date? = nil + ) { + self.authorizedKey = authorizedKey + self.algorithm = algorithm + self.publicKeyBlob = publicKeyBlob + self.serial = serial + self.keyID = keyID + self.validAfter = validAfter + self.validBefore = validBefore + } + +} + +private nonisolated struct SSHWireReader { + public let data: Data + public var offset = 0 + public var isAtEnd: Bool { offset == data.count } + + public mutating func readUInt32() -> UInt32? { + guard offset + 4 <= data.count else { return nil } + let value = data[offset..<(offset + 4)].reduce(UInt32(0)) { + ($0 << 8) | UInt32($1) + } + offset += 4 + return value + } + + public mutating func readUInt64() -> UInt64? { + guard offset + 8 <= data.count else { return nil } + let value = data[offset..<(offset + 8)].reduce(UInt64(0)) { + ($0 << 8) | UInt64($1) + } + offset += 8 + return value + } + + public mutating func readField() -> Data? { + guard let length = readUInt32(), offset + Int(length) <= data.count + else { return nil } + defer { offset += Int(length) } + return data[offset..<(offset + Int(length))] + } + + public mutating func readFields(count: Int) throws -> [Data] { + var result: [Data] = [] + for _ in 0.. Data { + var length = UInt32(data.count).bigEndian + return withUnsafeBytes(of: &length) { Data($0) } + data + } +} diff --git a/Frameworks/TermindDomain/Transfers/RemotePath.swift b/Frameworks/TermindDomain/Transfers/RemotePath.swift new file mode 100644 index 00000000..dacf75d0 --- /dev/null +++ b/Frameworks/TermindDomain/Transfers/RemotePath.swift @@ -0,0 +1,102 @@ +import Foundation + +public nonisolated enum RemotePath { + public struct Component: Identifiable, Equatable, Sendable { + public var name: String + public var path: String + public var id: String { path } + } + + public static func normalized(_ path: String) -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "/" } + var components: [Substring] = [] + for component in trimmed.split(separator: "/") { + switch component { + case ".": continue + case "..": if !components.isEmpty { components.removeLast() } + default: components.append(component) + } + } + return "/" + components.joined(separator: "/") + } + + public static func join(_ base: String, _ name: String) -> String { + let base = normalized(base) + return base == "/" ? "/\(name)" : "\(base)/\(name)" + } + + public static func parent(of path: String) -> String { + let value = normalized(path) + guard value != "/" else { return "/" } + let parent = (value as NSString).deletingLastPathComponent + return parent.isEmpty ? "/" : parent + } + + public static func components(of path: String) -> [Component] { + let value = normalized(path) + var result = [Component(name: "/", path: "/")] + guard value != "/" else { return result } + var current = "" + for name in value.split(separator: "/").map(String.init) { + current += "/" + name + result.append(Component(name: name, path: current)) + } + return result + } + + public static func displayed(_ path: String, home: RemoteHomeResolutionState) -> String { + let value = normalized(path) + guard case .resolved(let proposedHome) = home else { return value } + let home = normalized(proposedHome) + if value == home { return "~" } + if home == "/" { return "~" + value } + guard value.hasPrefix(home + "/") else { return value } + return "~" + value.dropFirst(home.count) + } + + public static func displayedComponents( + of path: String, home: RemoteHomeResolutionState + ) -> [Component] { + let value = normalized(path) + guard case .resolved(let proposedHome) = home else { return components(of: value) } + let home = normalized(proposedHome) + guard value == home || home == "/" || value.hasPrefix(home + "/") else { + return components(of: value) + } + var result = [Component(name: "~", path: home)] + guard value != home else { return result } + var current = home + let suffix = home == "/" ? value.dropFirst() : value.dropFirst(home.count + 1) + for name in suffix.split(separator: "/").map(String.init) { + current = join(current, name) + result.append(Component(name: name, path: current)) + } + return result + } + + public static func keepBothCandidate(for path: String, index: Int) -> String { + let ns = path as NSString + let parent = ns.deletingLastPathComponent + let name = ns.lastPathComponent as NSString + let ext = name.pathExtension + let stem = name.deletingPathExtension + let candidate = ext.isEmpty ? "\(stem).\(index)" : "\(stem).\(index).\(ext)" + return parent == "/" ? "/\(candidate)" : "\(parent)/\(candidate)" + } +} + +public nonisolated enum RemoteHomeResolutionState: Hashable, Sendable { + case pending + case resolved(String) + case unavailable + + public var resolvedDirectory: String? { + guard case .resolved(let path) = self else { return nil } + return RemotePath.normalized(path) + } + + public var isPending: Bool { + if case .pending = self { true } else { false } + } +} diff --git a/Frameworks/TermindDomain/Transfers/SFTPPermissionDraft.swift b/Frameworks/TermindDomain/Transfers/SFTPPermissionDraft.swift new file mode 100644 index 00000000..bb4cc3cd --- /dev/null +++ b/Frameworks/TermindDomain/Transfers/SFTPPermissionDraft.swift @@ -0,0 +1,66 @@ +import Foundation + +/// Editable Unix permission bits for the SFTP permissions sheet. +/// +/// The remote attributes also contain a file-type mask. SETSTAT only needs the +/// four chmod digits, so the draft deliberately keeps those bits separate. +public nonisolated struct SFTPPermissionDraft: Equatable, Sendable { + public static let editableMask: UInt32 = 0o7777 + + public let originalMode: UInt32 + public private(set) var mode: UInt32 + public private(set) var text: String + + public init(permissions: UInt32) { + let mode = permissions & Self.editableMask + originalMode = mode + self.mode = mode + text = Self.octalString(for: mode) + } + + public var parsedMode: UInt32? { + guard !text.isEmpty else { return nil } + return UInt32(text, radix: 8).map { $0 & Self.editableMask } + } + + public var canSave: Bool { + guard let parsedMode else { return false } + return parsedMode != originalMode + } + + public func contains(_ permission: UInt32) -> Bool { + mode & permission != 0 + } + + /// Accepts a transient editor value without destroying the last valid mode. + /// This lets the field be cleared while the user types a replacement. + @discardableResult + public mutating func accept(_ candidate: String) -> Bool { + guard candidate.count <= 4, + candidate.allSatisfy({ ("0"..."7").contains($0) }) + else { return false } + text = candidate + if let parsedMode { mode = parsedMode } + return true + } + + public mutating func normalizeText() { + guard let parsedMode else { return } + mode = parsedMode + text = Self.octalString(for: parsedMode) + } + + public mutating func set(_ permission: UInt32, enabled: Bool) { + if enabled { + mode |= permission + } else { + mode &= ~permission + } + mode &= Self.editableMask + text = Self.octalString(for: mode) + } + + public static func octalString(for permissions: UInt32) -> String { + String(format: "%04o", permissions & editableMask) + } +} diff --git a/Frameworks/TermindDomain/Vault/VaultSnapshot.swift b/Frameworks/TermindDomain/Vault/VaultSnapshot.swift new file mode 100644 index 00000000..19acbf74 --- /dev/null +++ b/Frameworks/TermindDomain/Vault/VaultSnapshot.swift @@ -0,0 +1,39 @@ +import Foundation + +public nonisolated struct VaultSnapshot: Sendable { + public var hosts: [Host] = [] + public var groups: [HostGroup] = [] + public var identities: [Identity] = [] + public var keys: [SSHKeyRef] = [] + public var knownHosts: [KnownHostEntry] = [] + public var snippets: [Snippet] = [] + public var snippetPackages: [SnippetPackage] = [] + public var portForwardings: [PortForwardingRule] = [] + public init( + hosts: [Host] = [], groups: [HostGroup] = [], identities: [Identity] = [], keys: [SSHKeyRef] = [], + knownHosts: [KnownHostEntry] = [], snippets: [Snippet] = [], snippetPackages: [SnippetPackage] = [], + portForwardings: [PortForwardingRule] = [] + ) { + self.hosts = hosts + self.groups = groups + self.identities = identities + self.keys = keys + self.knownHosts = knownHosts + self.snippets = snippets + self.snippetPackages = snippetPackages + self.portForwardings = portForwardings + } + +} + +public nonisolated struct VaultRemoteApplyResult: Sendable { + public var snapshot: VaultSnapshot + public var acceptedKeys: Set + public var needsUpload: Set + public init(snapshot: VaultSnapshot, acceptedKeys: Set, needsUpload: Set) { + self.snapshot = snapshot + self.acceptedKeys = acceptedKeys + self.needsUpload = needsUpload + } + +} diff --git a/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift b/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift new file mode 100644 index 00000000..6f5801e1 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Commerce/ProAccessController.swift @@ -0,0 +1,405 @@ +import Foundation +import StoreKit +import TermindDomain + +@MainActor +@Observable +public final class ProAccessController { + public static let productID = "com.akinokaede.termind.pro" + public static let trialProductID = "com.akinokaede.termind.pro.trial" + public static let reviewDemoCode = "TERMIND-REVIEW-2026" + + private static let reviewDemoDefaultsKey = "commerce.pro.review-demo-expired.v1" + + private let defaults: UserDefaults? + private let now: () -> Date + private var transactionUpdates: Task? + private var refreshTask: Task? + private var accessTask: Task? + private var metadataTask: Task? + private var productsTask: Task<[Product], any Error>? + private var trialExpirationTask: Task? + private var refreshGeneration: UInt64 = 0 + private var metadataError: ProAccessControllerError? + + public private(set) var status: EntitlementStatus + public private(set) var product: Product? + public private(set) var trialProduct: Product? + public private(set) var error: ProAccessControllerError? + public private(set) var isProcessing = false + public private(set) var pendingProductID: String? + public private(set) var workspaceCompositionGrantedForLaunch: Bool + public private(set) var isReviewDemoModeEnabled: Bool + public var isPaywallPresented = false + public var requestedFeature: ProFeature? + + public var displayPrice: String? { product?.displayPrice } + public var hasProAccess: Bool { status.hasProAccess } + public var canPurchase: Bool { + status.canPurchase && !isProcessing && pendingProductID != Self.productID + } + public var canStartTrial: Bool { + status == .free(trialAvailable: true) && !isProcessing + && pendingProductID != Self.trialProductID && product != nil + && trialProduct.map(Self.isFreeTrialProduct) == true + } + public var message: String? { + if let error { return error.localizedDescription } + if pendingProductID != nil { return ProAccessControllerError.purchasePending.localizedDescription } + if status.canPurchase, let metadataError { + if metadataError == .trialUnavailable, status != .free(trialAvailable: true) { return nil } + return metadataError.localizedDescription + } + return nil + } + + public static func live() -> ProAccessController { ProAccessController(defaults: .standard) } + + public static func unrestrictedForTesting() -> ProAccessController { + testing(status: .unrestrictedForTesting) + } + + public static func testing( + status: EntitlementStatus, + reviewDefaults: UserDefaults? = nil + ) -> ProAccessController { + let controller = ProAccessController(defaults: reviewDefaults, observeStore: false) + controller.status = status + controller.workspaceCompositionGrantedForLaunch = status.hasProAccess + return controller + } + + public init( + defaults: UserDefaults?, + now: @escaping () -> Date = { .now }, + observeStore: Bool = true + ) { + self.defaults = defaults + self.now = now + isReviewDemoModeEnabled = defaults?.bool(forKey: Self.reviewDemoDefaultsKey) ?? false + status = .loading + workspaceCompositionGrantedForLaunch = false + guard observeStore else { return } + transactionUpdates = Task { [weak self] in + for await update in Transaction.updates { + guard !Task.isCancelled, let self else { return } + await self.handleTransactionUpdate(update) + } + } + refreshTask = Task { [weak self] in await self?.refresh() } + metadataTask = Task { [weak self] in await self?.reloadProducts() } + } + + isolated deinit { + transactionUpdates?.cancel() + refreshTask?.cancel() + accessTask?.cancel() + metadataTask?.cancel() + productsTask?.cancel() + trialExpirationTask?.cancel() + } + + public func canUse(_ feature: ProFeature) -> Bool { + if feature == .workspaceComposition { return workspaceCompositionGrantedForLaunch } + return status.hasProAccess + } + + @discardableResult + public func require(_ feature: ProFeature) -> Bool { + guard !canUse(feature) else { return true } + requestedFeature = feature + isPaywallPresented = true + return false + } + + public func presentPaywall(for feature: ProFeature? = nil) { + requestedFeature = feature + isPaywallPresented = true + } + + @discardableResult + public func activateReviewDemo(using code: String) -> Bool { + guard code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == Self.reviewDemoCode else { + return false + } + refreshGeneration &+= 1 + accessTask?.cancel() + accessTask = nil + isReviewDemoModeEnabled = true + defaults?.set(true, forKey: Self.reviewDemoDefaultsKey) + workspaceCompositionGrantedForLaunch = false + requestedFeature = nil + error = nil + apply(.trialExpired) + return true + } + + public func deactivateReviewDemo() async { + disableReviewDemo() + await refresh() + } + + /// StoreKit's local transactions resolve access independently of network product metadata. + public func refresh() async { await refresh(including: nil) } + + private func refresh(including transaction: Transaction?) async { + if status == .unrestrictedForTesting { return } + // Callers awaiting refresh must also wait for a newer, authoritative + // transaction refresh. Otherwise a timer/activation can supersede the + // purchase preflight while leaving the caller with stale eligibility. + if accessTask == nil || transaction != nil { + accessTask?.cancel() + refreshGeneration &+= 1 + accessTask = Task { [weak self] in + await self?.currentStatus(including: transaction) ?? .loading + } + } + let generation = refreshGeneration + guard let task = accessTask else { return } + let resolved = await task.value + guard generation == refreshGeneration else { + if accessTask != nil { await refresh() } + return + } + guard accessTask != nil else { return } + accessTask = nil + if case .purchased = resolved { + disableReviewDemo() + pendingProductID = nil + } else if case .trialActive = resolved, pendingProductID == Self.trialProductID { + pendingProductID = nil + } + apply(isReviewDemoModeEnabled ? .trialExpired : resolved) + } + + public func startTrial() async { await purchaseProduct(Self.trialProductID) } + public func purchase() async { await purchaseProduct(Self.productID) } + + private func purchaseProduct(_ id: String) async { + guard !isProcessing, pendingProductID != id, status.canPurchase else { return } + isProcessing = true + error = nil + defer { isProcessing = false } + await refresh() + guard status.canPurchase else { return } + if id == Self.trialProductID, status != .free(trialAvailable: true) { + if case .trialActive = status { + isPaywallPresented = false + } else { + error = status == .verificationFailed ? .purchaseVerificationFailed : .trialAlreadyUsed + } + return + } + do { + // Recheck metadata immediately before purchase, even if the UI cached it. + try await fetchProducts() + guard let product else { throw ProAccessControllerError.productUnavailable } + let selected: Product + if id == Self.trialProductID { + guard let trialProduct, Self.isFreeTrialProduct(trialProduct) else { + throw ProAccessControllerError.trialUnavailable + } + selected = trialProduct + } else { + selected = product + } + switch try await selected.purchase() { + case .success(let verification): + guard case .verified(let transaction) = verification, transaction.productID == id else { + error = .purchaseVerificationFailed + return + } + disableReviewDemo() + await refresh(including: transaction) + // Delivery/denial is resolved before acknowledging the transaction. + await transaction.finish() + if status.hasProAccess { + isPaywallPresented = false + } else if status == .trialExpired { + error = .trialAlreadyUsed + } + case .pending: + pendingProductID = id + case .userCancelled: + break + @unknown default: + error = .purchaseVerificationFailed + } + } catch StoreKitError.userCancelled { + // Cancellation is not an entitlement change or a failure to display. + } catch let purchaseError { + error = purchaseError as? ProAccessControllerError ?? .operationFailed(purchaseError.localizedDescription) + } + } + + public func restorePurchases() async { + guard !isProcessing else { return } + isProcessing = true + error = nil + defer { isProcessing = false } + do { + try await AppStore.sync() + await refresh() + switch status { + case .purchased, .trialActive: + isPaywallPresented = false + case .trialExpired: + error = .trialAlreadyUsed + case .verificationFailed: + error = .purchaseVerificationFailed + default: + error = .purchaseNotFound + } + } catch { + self.error = .operationFailed(error.localizedDescription) + } + } + + public func reloadProducts() async { + do { + try await fetchProducts() + } catch { + product = nil + trialProduct = nil + metadataError = .productMetadataUnavailable + } + } + + private func fetchProducts() async throws { + let task = productsTask ?? Task { try await Product.products(for: [Self.productID, Self.trialProductID]) } + productsTask = task + defer { productsTask = nil } + let loaded = try await task.value + product = loaded.first { $0.id == Self.productID && $0.type == .nonConsumable } + trialProduct = loaded.first { $0.id == Self.trialProductID } + if product == nil { + metadataError = .productUnavailable + } else if trialProduct.map(Self.isFreeTrialProduct) != true { + metadataError = .trialUnavailable + } else { + metadataError = nil + } + } + + public static func isFreeTrialProduct(_ product: Product) -> Bool { + product.id == trialProductID && product.type == .nonConsumable && product.price == .zero + && !product.isFamilyShareable + } + + private func currentStatus(including update: Transaction?) async -> EntitlementStatus { + // The verified purchase/update can arrive before StoreKit's history cache. + if let update, update.productID == Self.productID, update.productType == .nonConsumable, + update.revocationDate == nil + { + return .purchased(update.ownershipType == .familyShared ? .familyShared : .purchased) + } + var verificationFailed = false + for await result in Transaction.currentEntitlements { + switch result { + case .verified(let transaction) + where transaction.productID == Self.productID && transaction.revocationDate == nil + && transaction.productType == .nonConsumable + && !(update?.productID == Self.productID && update?.revocationDate != nil + && update?.originalID == transaction.originalID): + return .purchased(transaction.ownershipType == .familyShared ? .familyShared : .purchased) + case .unverified(let transaction, _) where transaction.productID == Self.productID: + verificationFailed = true + default: break + } + } + var trials: [ProTrial] = [] + if let update, update.productID == Self.trialProductID { trials.append(Self.trialRecord(update)) } + var trialVerificationFailed = false + // Revoked products disappear from currentEntitlements but still count as claimed. + for await result in Transaction.all { + switch result { + case .verified(let transaction) where transaction.productID == Self.trialProductID: + trials.append(Self.trialRecord(transaction)) + case .unverified(let transaction, _) where transaction.productID == Self.trialProductID: + trialVerificationFailed = true + default: break + } + } + // An unverifiable trial-history entry must never become fresh eligibility. + if trialVerificationFailed { return .verificationFailed } + guard let trial = ProTrial.earliest(trials) else { + return verificationFailed ? .verificationFailed : .free(trialAvailable: true) + } + return trial.status(at: TrialClock(defaults: defaults).effectiveNow(for: trial, now: now())) + } + + private static func trialRecord(_ transaction: Transaction) -> ProTrial { + ProTrial( + originalID: transaction.originalID, purchasedAt: transaction.originalPurchaseDate, + isRevoked: transaction.revocationDate != nil || transaction.ownershipType != .purchased + || transaction.productType != .nonConsumable + ) + } + + public func handleTransactionUpdate(_ update: VerificationResult) async { + guard [Self.productID, Self.trialProductID].contains(update.unsafePayloadValue.productID) else { return } + if case .verified(let transaction) = update { + error = nil + if pendingProductID == transaction.productID { pendingProductID = nil } + await refresh(including: transaction) + await transaction.finish() + } else { + error = .purchaseVerificationFailed + await refresh() + } + } + + private func apply(_ next: EntitlementStatus) { + status = next + if next.hasProAccess { workspaceCompositionGrantedForLaunch = true } + trialExpirationTask?.cancel() + if case .trialActive(let expiresAt) = next { + trialExpirationTask = Task { [weak self] in + // Recheck at least once a minute, including clock changes and sleep/wake. + let remaining = min(60, max(0, expiresAt.timeIntervalSince(self?.now() ?? .now))) + try? await Task.sleep(for: .seconds(remaining)) + guard !Task.isCancelled else { return } + await self?.refresh() + } + } + } + + private func disableReviewDemo() { + isReviewDemoModeEnabled = false + defaults?.removeObject(forKey: Self.reviewDemoDefaultsKey) + } +} + +public nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sendable { + case productUnavailable + case productMetadataUnavailable + case trialUnavailable + case trialAlreadyUsed + case purchaseVerificationFailed + case purchasePending + case operationFailed(String) + case purchaseNotFound + + public var errorDescription: String? { + switch self { + case .productUnavailable: + String(localized: "Termind Pro is not available from the App Store right now.") + case .productMetadataUnavailable: + String(localized: "The App Store is temporarily unavailable. You can keep using already unlocked features.") + case .trialUnavailable: + String(localized: "The free trial is unavailable from the App Store right now. Please try again later.") + case .trialAlreadyUsed: + String( + localized: + "This App Store account has already used the free trial. Restoring it does not restart the trial.") + case .operationFailed(let description): + description + case .purchaseVerificationFailed: + String(localized: "The App Store could not verify this purchase.") + case .purchasePending: + String(localized: "The purchase is waiting for App Store approval.") + case .purchaseNotFound: + String(localized: "No Termind Pro purchase or free trial was found for this App Store account.") + } + } +} diff --git a/Frameworks/TermindInfrastructure/Commerce/TrialClock.swift b/Frameworks/TermindInfrastructure/Commerce/TrialClock.swift new file mode 100644 index 00000000..ecd5cf39 --- /dev/null +++ b/Frameworks/TermindInfrastructure/Commerce/TrialClock.swift @@ -0,0 +1,20 @@ +import Foundation +import TermindDomain + +/// This watermark can only shorten a verified trial, never grant one. It is a +/// best-effort clock rollback guard, not a second store of purchase eligibility. +public struct TrialClock { + public let defaults: UserDefaults? + + public func effectiveNow(for trial: ProTrial, now: Date) -> Date { + let key = "commerce.pro.trial-clock.\(trial.originalID)" + let lastObserved = defaults?.object(forKey: key) as? Date ?? trial.purchasedAt + let effective = max(now, lastObserved, trial.purchasedAt) + defaults?.set(effective, forKey: key) + return effective + } + public init(defaults: UserDefaults?) { + self.defaults = defaults + } + +} diff --git a/Frameworks/TermindPersistence/ModelContext+Commit.swift b/Frameworks/TermindPersistence/ModelContext+Commit.swift new file mode 100644 index 00000000..46a903d1 --- /dev/null +++ b/Frameworks/TermindPersistence/ModelContext+Commit.swift @@ -0,0 +1,18 @@ +import SwiftData + +@MainActor +extension ModelContext { + public func savePendingChanges() throws { + try commit {} + } + + public func commit(_ operation: () throws -> Void) throws { + do { + try operation() + try save() + } catch { + rollback() + throw error + } + } +} diff --git a/Frameworks/TermindPersistence/SharedRepositoryAliases.swift b/Frameworks/TermindPersistence/SharedRepositoryAliases.swift new file mode 100644 index 00000000..fe9a1b3a --- /dev/null +++ b/Frameworks/TermindPersistence/SharedRepositoryAliases.swift @@ -0,0 +1,6 @@ +public typealias SharedHostPersistence = HostPersistence +public typealias SharedVaultSyncPersistenceError = VaultSyncPersistenceError +public typealias SharedVaultRemoteMerge = VaultRemoteMerge +public typealias SharedVaultOutgoingEntry = VaultOutgoingEntry +public typealias SharedVaultSyncJournal = VaultSyncJournal +public typealias SharedSwiftDataHostRepository = SwiftDataHostRepository diff --git a/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift b/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift new file mode 100644 index 00000000..cbbc2fe0 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataAIConfigurationStore.swift @@ -0,0 +1,118 @@ +import AgentKit +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +@MainActor +public final class AIPersistence { + private let context: ModelContext + + public init(context: ModelContext) { + self.context = context + } + + public func load() throws -> AIConfigurationSnapshot { + try load(preservingUnresolvedReferences: false) + } + + public func load(preservingUnresolvedReferences: Bool) throws -> AIConfigurationSnapshot { + let modelRows = try context.fetch( + FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) + ) + let modelsByProvider = Dictionary(grouping: modelRows, by: \.providerID) + let providerRows = try context.fetch( + FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) + ) + var seenProviders: Set = [] + var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] + let providers = providerRows.compactMap { row -> ModelProvider? in + guard seenProviders.insert(row.id).inserted else { return nil } + var seenModels: Set = [] + let models: [AIModel] = (modelsByProvider[row.id] ?? []).compactMap { modelRow -> AIModel? in + guard seenModels.insert(modelRow.modelID).inserted else { return nil } + return modelRow.value + } + endpoints[row.id] = row.endpoint + return row.value(models: models) + } + + func valid(_ ref: AIModelRef) -> Bool { + providers.first(where: { $0.id == ref.modelProviderID })?.model(ref.modelID) != nil + } + + var roles: [AIRole: AIModelRef] = [:] + for row in try context.fetch(FetchDescriptor()) { + guard let role = AIRole(rawValue: row.role) else { continue } + let ref = AIModelRef(modelProviderID: row.providerID, modelID: row.modelID) + if preservingUnresolvedReferences || valid(ref) { roles[role] = ref } + } + + let selectionRow = try context.fetch(FetchDescriptor()).first + let lastSelectedModel: AIModelRef? = { + guard let providerID = selectionRow?.providerID, + let modelID = selectionRow?.modelID + else { return nil } + let ref = AIModelRef(modelProviderID: providerID, modelID: modelID) + return preservingUnresolvedReferences || valid(ref) ? ref : nil + }() + + let mcpRows = try context.fetch( + FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) + ) + var seenMCPServers: Set = [] + let mcpServers = mcpRows.compactMap { row -> MCPServer? in + guard seenMCPServers.insert(row.id).inserted else { return nil } + return row.value + } + + return AIConfigurationSnapshot( + providers: providers, + endpoints: endpoints, + roles: roles, + lastSelectedModel: lastSelectedModel, + mcpServers: mcpServers + ) + } + + public func save(_ snapshot: AIConfigurationSnapshot) throws { + try context.commit { try replace(snapshot) } + } + + /// Stages rows in the caller-owned transaction. + public func replace(_ snapshot: AIConfigurationSnapshot) throws { + try context.delete(model: PersistentAIModel.self) + try context.delete(model: PersistentModelProvider.self) + try context.delete(model: PersistentAIRoleAssignment.self) + try context.delete(model: PersistentAISelection.self) + try context.delete(model: PersistentMCPServer.self) + + for (providerIndex, provider) in snapshot.providers.enumerated() { + // A provider with no recorded endpoint is read back as + // `Automatic` over its composed URL, which resolves to the same + // format it already has — see `AIConfigurationModel.endpoints`. + let endpoint = + snapshot.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL) + context.insert( + PersistentModelProvider(provider, endpoint: endpoint, sortIndex: providerIndex) + ) + for (modelIndex, model) in provider.models.enumerated() { + context.insert( + PersistentAIModel(model, providerID: provider.id, sortIndex: modelIndex) + ) + } + } + for role in AIRole.allCases { + guard let ref = snapshot.roles[role] else { continue } + context.insert(PersistentAIRoleAssignment(role: role, ref: ref)) + } + context.insert(PersistentAISelection(snapshot.lastSelectedModel)) + for (serverIndex, server) in snapshot.mcpServers.enumerated() { + context.insert(PersistentMCPServer(server, sortIndex: serverIndex)) + } + } +} + +extension AIPersistence: AIConfigurationPersisting {} + +public typealias SharedAIPersistence = AIPersistence diff --git a/Frameworks/TermindPersistence/SwiftDataAISecretStore.swift b/Frameworks/TermindPersistence/SwiftDataAISecretStore.swift new file mode 100644 index 00000000..d9147eb8 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataAISecretStore.swift @@ -0,0 +1,103 @@ +import Foundation +import SwiftData +import TermindApplication + +/// Migration metadata is local. The reference is shared; the secret never enters SwiftData. +@MainActor +public final class AISecretStore: AISecretStoring { + private let context: ModelContext + private let keychain: any SecretStoring + public var uploadsAuthorized = false + + public init(context: ModelContext, keychain: any SecretStoring) { + self.context = context + self.keychain = keychain + } + + public func string(for reference: String) throws -> String? { + guard !reference.isEmpty else { return nil } + let location = try location(for: reference) + guard !location.pendingDeletion else { return nil } + return try keychain.string(for: reference, storage: location.usesICloud ? .iCloud : .thisDevice) + } + + public func setString(_ value: String?, for reference: String) throws { + guard !reference.isEmpty else { return } + let location = try location(for: reference) + if value == nil { + location.pendingDeletion = true + try context.save() + try finishDeletion(location) + return + } + let cloud = location.usesICloud || uploadsAuthorized + try keychain.setString(value, for: reference, storage: cloud ? .iCloud : .thisDevice) + if cloud { + guard try keychain.string(for: reference, storage: .iCloud) == value else { + throw CocoaError(.coderInvalidValue) + } + } + location.usesICloud = cloud + location.pendingDeletion = false + try context.save() + if cloud { try keychain.setString(nil, for: reference, storage: .thisDevice) } + } + + /// Called only after the coordinator has checked the account and merge consent. + public func migrate(_ references: Set) throws { + guard uploadsAuthorized else { return } + for row in try context.fetch(FetchDescriptor()) where row.pendingDeletion { + try finishDeletion(row) + } + for reference in references.sorted() where !reference.isEmpty { + let row = try location(for: reference) + guard !row.pendingDeletion else { continue } + if let local = try keychain.string(for: reference, storage: .thisDevice) { + // A previously synced value can be newer than a leftover local copy. + if try keychain.string(for: reference, storage: .iCloud) == nil && !row.usesICloud { + try keychain.setString(local, for: reference, storage: .iCloud) + guard try keychain.string(for: reference, storage: .iCloud) == local else { + throw CocoaError(.coderInvalidValue) + } + } + if try keychain.string(for: reference, storage: .iCloud) != nil { + row.usesICloud = true + try context.save() + try keychain.setString(nil, for: reference, storage: .thisDevice) + } + } else { + row.usesICloud = true + try context.save() + } + } + } + + public func acceptRemoteReferences(_ references: Set) throws { + try context.commit { + for reference in references where !reference.isEmpty { + try location(for: reference).usesICloud = true + } + } + } + + private func finishDeletion(_ row: PersistentAISecretLocation) throws { + if row.usesICloud { try keychain.setString(nil, for: row.reference, storage: .iCloud) } + try keychain.setString(nil, for: row.reference, storage: .thisDevice) + // Retain the deletion marker so a delayed Keychain delivery cannot be used. + } + + private func location(for reference: String) throws -> PersistentAISecretLocation { + guard reference.hasPrefix("termind.ai."), reference.count > "termind.ai.".count else { + throw CocoaError(.coderValueNotFound) + } + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.reference == reference }) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { return row } + let row = PersistentAISecretLocation(reference: reference) + context.insert(row) + return row + } +} + +public typealias SharedAISecretStore = AISecretStore diff --git a/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift new file mode 100644 index 00000000..e07322c4 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataAgentRunRepository.swift @@ -0,0 +1,659 @@ +import AgentKit +import Foundation +import SwiftData + +@MainActor +public final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { + private let context: ModelContext + private var deletedConversationIDs = Set() + private var deletedRunIDs = Set() + + public init(context: ModelContext) { + self.context = context + interruptUnfinishedRuns() + } + + @MainActor + public func save(_ snapshot: AgentRunSnapshot) async throws { + guard !deletedConversationIDs.contains(snapshot.conversationID) else { + deletedRunIDs.insert(snapshot.id) + return + } + let runID = snapshot.id + let runDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == runID }) + if let row = try context.fetch(runDescriptor).first { + row.update(snapshot) + } else { + context.insert(PersistentAgentRun(snapshot)) + } + + let conversationID = snapshot.conversationID + let conversationDescriptor = FetchDescriptor( + predicate: #Predicate { $0.id == conversationID } + ) + // No title seeding here. `title` means "the generated summary", and + // empty means there is not one yet — which is what lets the UI decide to + // fall back to the opening line rather than having that line frozen into + // storage as though it were a name someone chose. + if let conversation = try context.fetch(conversationDescriptor).first { + conversation.updatedAt = .now + } else { + context.insert( + PersistentAgentConversation( + id: conversationID, title: "", createdAt: snapshot.startedAt + )) + } + + // Matched on message id, not on this run's id. A conversation's earlier + // turns are replayed into every later run, so filtering by `runID` made + // each save re-insert the whole history — colliding with the unique + // constraint and rewriting rows that were already correct. + let durableMessages = snapshot.messages.filter { !$0.isCompaction } + let ids = Set(durableMessages.map(\.id)) + let stored = try context.fetch( + FetchDescriptor( + predicate: #Predicate { ids.contains($0.id) } + )) + let storedIDs = Set(stored.map(\.id)) + var inserted = false + for (sequence, message) in durableMessages.enumerated() + where !storedIDs.contains(message.id) { + context.insert( + PersistentAgentMessage( + message, runID: runID, sequence: sequence + )) + insertContextAttachments(for: message) + inserted = true + } + guard inserted || context.hasChanges else { return } + try context.save() + } + + @MainActor + public func persistCompaction( + _ record: AgentCompactionRecord, conversationID: UUID + ) async throws { + guard !deletedConversationIDs.contains(conversationID) else { + throw AgentConversationPersistenceError.conversationNotFound + } + let conversations = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.id == conversationID } + )) + guard conversations.first != nil else { + throw AgentConversationPersistenceError.conversationNotFound + } + if let boundary = record.compactedThroughMessageID { + let runIDs = Set( + try context.fetch(FetchDescriptor()) + .filter { $0.conversationID == conversationID }.map(\.id)) + let rows = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.id == boundary } + )) + guard rows.contains(where: { runIDs.contains($0.runID) }) else { + throw AgentConversationPersistenceError.messageNotFound + } + } + let recordID = record.id + let existing = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.id == recordID } + )) + guard existing.isEmpty else { return } + context.insert(PersistentAgentCompaction(record, conversationID: conversationID)) + try context.savePendingChanges() + } + + @MainActor + public func record(_ event: AgentEvent, runID: UUID) async throws { + guard !deletedRunIDs.contains(runID) else { return } + switch event { + case .toolProposed(let invocation, _): + try upsertInvocation(invocation, state: "proposed") + case .toolStarted(let invocation): + try upsertInvocation(invocation, state: "started") + case .toolFinished(let invocation, let result): + try upsertInvocation(invocation, state: "finished") + context.insert(PersistentAgentToolResult(result, invocation: invocation)) + case .reviewStarted(let request): + context.insert( + PersistentAgentReviewRecord( + runID: runID, invocationID: request.invocation.id, decision: nil + )) + case .reviewFinished(let request, let decision): + context.insert( + PersistentAgentReviewRecord( + runID: runID, invocationID: request.invocation.id, decision: decision + )) + case .reviewFailed(let request, let reason, let timedOut): + context.insert( + PersistentAgentReviewRecord( + runID: runID, invocationID: request.invocation.id, + failure: reason, timedOut: timedOut + )) + default: + // Nothing to write, so nothing to save. `AgentRuntime` already + // filters on `isDurable`; this is the second half of the same + // guarantee, for any caller that does not. + return + } + try context.save() + } + + @MainActor + public func loadConversations() async throws -> [StoredAgentConversation] { + let conversations = try context.fetch( + FetchDescriptor( + sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] + )) + let orderRows = try context.fetch(FetchDescriptor()) + let sortIndexByConversation = Dictionary( + uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0.sortIndex) } + ) + let runs = try context.fetch(FetchDescriptor()) + let messages = try context.fetch(FetchDescriptor()) + let compactionRows = try context.fetch(FetchDescriptor()) + let attachmentRows = try context.fetch( + FetchDescriptor() + ) + let attachmentsByMessage = Dictionary( + uniqueKeysWithValues: attachmentRows.map { ($0.messageID, $0.attachments) } + ) + let invocations = try context.fetch(FetchDescriptor()) + let results = try context.fetch(FetchDescriptor()) + let reviews = try context.fetch(FetchDescriptor()) + return conversations.map { conversation in + let matchingRuns = runs.filter { $0.conversationID == conversation.id } + .sorted { $0.startedAt < $1.startedAt } + let runIDs = Set(matchingRuns.map(\.id)) + // Ordered by run first, then by time within it. Sorting the whole + // conversation on `createdAt` alone let two runs interleave whenever + // their message timestamps were close, which reorders a transcript + // the model is about to be shown. + let runOrder = Dictionary( + uniqueKeysWithValues: matchingRuns.enumerated().map { ($0.element.id, $0.offset) } + ) + let transcript = messages.filter { runIDs.contains($0.runID) } + .sorted { lhs, rhs in + let left = runOrder[lhs.runID] ?? 0 + let right = runOrder[rhs.runID] ?? 0 + if left != right { return left < right } + if lhs.sequence != rhs.sequence { return lhs.sequence < rhs.sequence } + if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt } + // Transcript grammar gives equal-position, equal-time rows + // a stable and useful final ordering. + let rank = { (role: String) -> Int in + switch AgentTranscriptRole(rawValue: role) { + case .user: 0 + case .assistant: 1 + case .tool: 2 + case nil: 3 + } + } + return rank(lhs.role) < rank(rhs.role) + } + .compactMap { row -> AgentTranscriptMessage? in + guard let role = AgentTranscriptRole(rawValue: row.role) else { return nil } + let calls = + (try? JSONDecoder().decode( + [AgentToolCall].self, from: row.toolCallsData + )) ?? [] + let images = + (try? JSONDecoder().decode( + [AgentImageAttachment].self, from: row.imagesData + )) ?? [] + let contextAttachments = attachmentsByMessage[row.id] ?? [] + let reasoning = + (try? JSONDecoder().decode( + [AgentReasoningBlock].self, from: row.reasoningData + )) ?? [] + return AgentTranscriptMessage( + id: row.id, role: role, text: row.text, + authoredText: row.authoredText, images: images, + contextAttachments: contextAttachments, + reasoning: reasoning, + toolCalls: calls, + toolCallID: row.toolCallID, toolName: row.toolName, + isError: row.isError, isCompaction: row.isCompaction, + createdAt: row.createdAt + ) + } + let messageRunIDs = Dictionary( + uniqueKeysWithValues: messages.filter { runIDs.contains($0.runID) } + .map { ($0.id, $0.runID) } + ) + let state = + matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) } + ?? .completed + let cards = invocations.filter { runIDs.contains($0.runID) } + .sorted { $0.createdAt < $1.createdAt } + .compactMap { storedToolCard($0, results: results, reviews: reviews) } + let projection = AgentCompactionProjection.restore( + messages: Self.recovering( + transcript, + results: results.filter { runIDs.contains($0.runID) }, + invocations: invocations.filter { runIDs.contains($0.runID) } + ), + records: compactionRows.filter { $0.conversationID == conversation.id } + .map(\.value) + ) + return StoredAgentConversation( + id: conversation.id, + messages: projection.messages, + modelTranscript: projection.modelTranscript, + compactions: projection.records, + messageRunIDs: messageRunIDs, + runs: matchingRuns.compactMap { run in + guard let state = AgentRunState(rawValue: run.state) else { return nil } + return AgentRunSummary( + id: run.id, state: state, startedAt: run.startedAt, + finishedAt: run.finishedAt + ) + }, + toolCards: cards, + lastRunState: state, title: conversation.title, + metadata: StoredAgentConversationMetadata( + customTitle: conversation.customTitle, + isPinned: conversation.isPinned, + sortIndex: sortIndexByConversation[conversation.id], + archivedAt: conversation.archivedAt, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt + ) + ) + } + } + + @MainActor + public func setTitle(_ title: String, conversationID: UUID) async throws { + guard !deletedConversationIDs.contains(conversationID) else { return } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == conversationID } + ) + // A title is model output about remote-influenced text, so it goes + // through the same redaction every other stored string does. + guard let row = try context.fetch(descriptor).first else { return } + row.title = AgentPersistenceRedactor.visibleText(String(title.prefix(80))) + row.updatedAt = .now + try context.save() + } + + @MainActor + public func setConversationMetadata( + _ metadata: StoredAgentConversationMetadata, conversationID: UUID + ) async throws { + guard !deletedConversationIDs.contains(conversationID) else { + throw AgentConversationPersistenceError.conversationNotFound + } + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == conversationID } + ) + guard let row = try context.fetch(descriptor).first else { + throw AgentConversationPersistenceError.conversationNotFound + } + row.customTitle = AgentPersistenceRedactor.visibleText( + String(metadata.customTitle.prefix(80)) + ) + row.isPinned = metadata.isPinned + row.archivedAt = metadata.archivedAt + row.updatedAt = metadata.updatedAt + try context.savePendingChanges() + } + + @MainActor + public func reorderConversations(_ updates: [StoredAgentConversationOrder]) async throws { + let ids = Set(updates.map(\.id)) + guard updates.count == ids.count else { + throw AgentConversationPersistenceError.conversationNotFound + } + let rows = try context.fetch( + FetchDescriptor( + predicate: #Predicate { ids.contains($0.id) } + )) + guard rows.count == ids.count else { + throw AgentConversationPersistenceError.conversationNotFound + } + let updatesByID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) + let orderRows = try context.fetch( + FetchDescriptor( + predicate: #Predicate { ids.contains($0.conversationID) } + )) + var orderByID = Dictionary( + uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0) } + ) + for row in rows { + guard let update = updatesByID[row.id] else { continue } + row.isPinned = update.isPinned + if let order = orderByID[row.id] { + order.sortIndex = update.sortIndex + } else { + let order = PersistentAgentConversationOrder( + conversationID: row.id, sortIndex: update.sortIndex + ) + context.insert(order) + orderByID[row.id] = order + } + } + try context.savePendingChanges() + } + + @MainActor + public func truncateConversation( + _ id: UUID, fromUserMessageID messageID: UUID, + replacingWith replacement: AgentTranscriptMessage + ) async throws { + let allRuns = try context.fetch(FetchDescriptor()) + let runs = allRuns.filter { $0.conversationID == id } + .sorted { $0.startedAt < $1.startedAt } + let allMessages = try context.fetch(FetchDescriptor()) + guard + let target = allMessages.first(where: { message in + message.id == messageID && runs.contains { $0.id == message.runID } + }), + let targetIndex = runs.firstIndex(where: { $0.id == target.runID }) + else { throw AgentConversationPersistenceError.messageNotFound } + + let removedRuns = Array(runs[targetIndex...]) + let removedRunIDs = Set(removedRuns.map(\.id)) + let removedMessageIDs = Set(allMessages.filter { removedRunIDs.contains($0.runID) }.map(\.id)) + deletedRunIDs.formUnion(removedRunIDs) + let earlierRunIDs = Set(runs[..()) + where row.conversationID == id + && row.compactedThroughMessageID.map(removedMessageIDs.contains) == true + { + context.delete(row) + } + + let replacementRun = AgentRunSnapshot( + conversationID: id, state: .completed, + permissionMode: .askForApproval, messages: [replacement], + startedAt: replacement.createdAt, finishedAt: replacement.createdAt + ) + context.insert(PersistentAgentRun(replacementRun)) + context.insert( + PersistentAgentMessage( + replacement, runID: replacementRun.id, sequence: 0 + )) + insertContextAttachments(for: replacement) + + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + if let conversation = try context.fetch(descriptor).first { + if removesOpeningTurn { conversation.title = "" } + conversation.updatedAt = .now + } + try context.save() + } catch { + context.rollback() + deletedRunIDs.subtract(removedRunIDs) + throw error + } + } + + @MainActor + public func createConversation(_ value: StoredAgentConversation) async throws { + guard !deletedConversationIDs.contains(value.id) else { + throw AgentConversationPersistenceError.conversationAlreadyExists + } + let conversationID = value.id + let descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == conversationID } + ) + guard try context.fetch(descriptor).isEmpty else { + throw AgentConversationPersistenceError.conversationAlreadyExists + } + + let metadata = value.metadata + let conversation = PersistentAgentConversation( + id: value.id, title: value.title, + createdAt: metadata.createdAt, updatedAt: metadata.updatedAt + ) + conversation.customTitle = metadata.customTitle + conversation.isPinned = metadata.isPinned + conversation.archivedAt = metadata.archivedAt + try context.commit { + context.insert(conversation) + if let sortIndex = metadata.sortIndex { + context.insert( + PersistentAgentConversationOrder( + conversationID: value.id, sortIndex: sortIndex + )) + } + + let summaries = + value.runs.isEmpty + ? [ + AgentRunSummary( + id: UUID(), state: value.lastRunState, + startedAt: metadata.createdAt, finishedAt: metadata.updatedAt + ) + ] + : value.runs.sorted { $0.startedAt < $1.startedAt } + for summary in summaries { + context.insert( + PersistentAgentRun( + AgentRunSnapshot( + id: summary.id, conversationID: value.id, state: summary.state, + permissionMode: .askForApproval, messages: [], + startedAt: summary.startedAt, finishedAt: summary.finishedAt + ))) + } + let validRunIDs = Set(summaries.map(\.id)) + let fallbackRunID = summaries[0].id + for (sequence, message) in value.messages.enumerated() { + guard !message.isCompaction else { continue } + context.insert( + PersistentAgentMessage( + message, + runID: value.messageRunIDs[message.id].flatMap { + validRunIDs.contains($0) ? $0 : nil + } ?? fallbackRunID, + sequence: sequence + )) + insertContextAttachments(for: message) + } + for record in value.compactions { + context.insert(PersistentAgentCompaction(record, conversationID: value.id)) + } + for card in value.toolCards { + var invocation = card.invocation + if !validRunIDs.contains(invocation.runID) { + invocation.runID = fallbackRunID + } + context.insert(PersistentAgentToolInvocation(invocation, state: card.state)) + if let result = card.result { + context.insert(PersistentAgentToolResult(result, invocation: invocation)) + } + if let review = card.review { + context.insert( + PersistentAgentReviewRecord( + runID: invocation.runID, invocationID: invocation.id, decision: review + )) + } + } + } + } + + @MainActor + public func deleteConversation(_ id: UUID) async throws { + deletedConversationIDs.insert(id) + let runs = try context.fetch(FetchDescriptor()) + .filter { $0.conversationID == id } + let runIDs = Set(runs.map(\.id)) + deletedRunIDs.formUnion(runIDs) + + try deleteGraph(runIDs: runIDs, runs: runs) + + let conversations = try context.fetch(FetchDescriptor()) + for row in conversations where row.id == id { context.delete(row) } + let orderRows = try context.fetch(FetchDescriptor()) + for row in orderRows where row.conversationID == id { context.delete(row) } + for row in try context.fetch(FetchDescriptor()) + where row.conversationID == id { context.delete(row) } + try context.save() + } + + private func deleteGraph(runIDs: Set, runs: [PersistentAgentRun]) throws { + for row in try context.fetch(FetchDescriptor()) + where runIDs.contains(row.runID) { context.delete(row) } + for row in try context.fetch(FetchDescriptor()) + where runIDs.contains(row.runID) { context.delete(row) } + for row in try context.fetch(FetchDescriptor()) + where runIDs.contains(row.runID) { context.delete(row) } + let messages = try context.fetch(FetchDescriptor()) + .filter { runIDs.contains($0.runID) } + let messageIDs = Set(messages.map(\.id)) + for row in try context.fetch(FetchDescriptor()) + where messageIDs.contains(row.messageID) { context.delete(row) } + for row in messages { context.delete(row) } + for row in runs { context.delete(row) } + } + + private func insertContextAttachments(for message: AgentTranscriptMessage) { + guard !message.contextAttachments.isEmpty else { return } + context.insert( + PersistentAgentContextAttachments( + messageID: message.id, attachments: message.contextAttachments + )) + } + + /// Puts back tool results that were recorded but never made it into the + /// transcript. + /// + /// The two are written on different schedules: a result row lands as its + /// tool finishes, while its transcript row waits for the run's next + /// snapshot save. A process killed in between — force quit, crash, the OS + /// reclaiming the app — leaves the call answered in storage and unanswered + /// in history, and an assistant turn with an unanswered call is rejected + /// outright on the next request. + /// + /// `AgentUnansweredToolCallRepair` would invent a placeholder for the same + /// gap, and does for anything still missing after this. Doing it here first + /// is what gives the model the output the tool actually produced instead of + /// being told the outcome is unknown when it is sitting in the database. + public static func recovering( + _ transcript: [AgentTranscriptMessage], + results: [PersistentAgentToolResult], + invocations: [PersistentAgentToolInvocation] + ) -> [AgentTranscriptMessage] { + guard transcript.contains(where: { $0.role == .assistant && !$0.toolCalls.isEmpty }) + else { return transcript } + + let names = Dictionary( + invocations.map { ($0.callID, $0.toolName) }, uniquingKeysWith: { first, _ in first } + ) + var recovered: [AgentTranscriptMessage] = [] + recovered.reserveCapacity(transcript.count) + var index = transcript.startIndex + while index < transcript.endIndex { + let message = transcript[index] + recovered.append(message) + index += 1 + guard message.role == .assistant, !message.toolCalls.isEmpty else { continue } + + var answered: Set = [] + while index < transcript.endIndex, transcript[index].role == .tool { + if let id = transcript[index].toolCallID { answered.insert(id) } + recovered.append(transcript[index]) + index += 1 + } + for call in message.toolCalls where !answered.contains(call.id) { + // The last recorded one: a call id is unique to its invocation, + // so more than one row can only mean a rewritten result. + guard let stored = results.last(where: { $0.callID == call.id }) else { continue } + recovered.append( + AgentTranscriptMessage( + role: .tool, text: stored.content, toolCallID: call.id, + toolName: names[call.id] ?? call.name, isError: stored.isError + )) + } + } + return recovered + } + + private func upsertInvocation( + _ invocation: AgentToolInvocation, state: String + ) throws { + let id = invocation.id + let fetch = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + if let row = try context.fetch(fetch).first { + let rank = ["proposed": 0, "started": 1, "finished": 2] + if rank[state, default: 0] > rank[row.state, default: 0] { row.state = state } + if row.sourceMessageID == nil { row.sourceMessageID = invocation.sourceMessageID } + } else { + context.insert(PersistentAgentToolInvocation(invocation, state: state)) + } + } + + private func storedToolCard( + _ row: PersistentAgentToolInvocation, + results: [PersistentAgentToolResult], + reviews: [PersistentAgentReviewRecord] + ) -> StoredAgentToolCard? { + guard let arguments = try? AgentJSONValue.decode(row.argumentsData) else { return nil } + let invocation = AgentToolInvocation( + id: row.id, runID: row.runID, + call: AgentToolCall(id: row.callID, name: row.toolName, arguments: arguments), + sourceMessageID: row.sourceMessageID, + targetHostID: row.targetHostID, createdAt: row.createdAt + ) + let result = results.last(where: { $0.invocationID == row.id }).map { stored in + AgentToolResult( + callID: stored.callID, content: stored.content, + isError: stored.isError, isTruncated: stored.isTruncated, + metadata: (try? JSONDecoder().decode( + [String: AgentJSONValue].self, from: stored.metadataData + )) ?? [:] + ) + } + let descriptor = AgentToolDescriptor( + name: row.toolName, summary: row.toolName, + inputSchema: .object(["type": .string("object")]), + target: row.targetHostID == nil ? .local : .host, + safety: .requiresAuthorization, presentation: result?.toolPresentation + ) + let review = reviews.filter { $0.invocationID == row.id } + .sorted { $0.createdAt < $1.createdAt } + .last.flatMap { stored -> SecurityReviewDecision? in + guard let verdict = stored.verdict.flatMap(SecurityReviewVerdict.init), + let risk = stored.risk.flatMap(SecurityReviewRisk.init), + let authorization = stored.userAuthorization.flatMap(SecurityReviewAuthorization.init) + else { return nil } + return SecurityReviewDecision( + verdict: verdict, risk: risk, userAuthorization: authorization, + reason: stored.reason ?? "" + ) + } + return StoredAgentToolCard( + invocation: invocation, descriptor: descriptor, state: row.state, + result: result, review: review + ) + } + + private func interruptUnfinishedRuns() { + do { + let rows = try context.fetch(FetchDescriptor()) + var changed = false + for row in rows { + guard let state = AgentRunState(rawValue: row.state), !state.isTerminal else { continue } + row.state = AgentRunState.interrupted.rawValue + row.finishedAt = .now + changed = true + } + if changed { try context.save() } + } catch { context.rollback() } + } +} + +public typealias SharedAgentRunRepository = SwiftDataAgentRunRepository diff --git a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift new file mode 100644 index 00000000..8520fc9e --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift @@ -0,0 +1,221 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +@MainActor +public final class SwiftDataHostRepository: HostRepository { + private let context: ModelContext + private var continuations: [UUID: AsyncStream>.Continuation] = [:] + public var didRecordVaultChanges: ((VaultMutationSet) -> Void)? + + public init(context: ModelContext) { + self.context = context + } + + public func snapshot() throws -> RepositoryChange { + let hidden = Set( + try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.entityType == "host" } + ) + ).map(\.entityID) + ) + let hosts = try context.fetch(FetchDescriptor()) + .map(\.value) + .filter { !hidden.contains($0.id) } + let groups = try context.fetch(FetchDescriptor()).map(\.value) + return RepositoryChange( + revision: try currentRevision(), + value: HostLibrarySnapshot(hosts: hosts, groups: groups), + reason: .initial + ) + } + + public func changes() -> AsyncStream> { + let id = UUID() + return AsyncStream { continuation in + continuations[id] = continuation + if let initial = try? snapshot() { continuation.yield(initial) } + continuation.onTermination = { [weak self] _ in + Task { @MainActor in self?.continuations[id] = nil } + } + } + } + + public func refresh() throws { + try publishMutation(reason: .refresh) + } + + public func upsertHost(_ host: Host) throws { + let hostID = host.id + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == hostID }) + descriptor.fetchLimit = 1 + if let existing = try context.fetch(descriptor).first { + existing.apply(host) + } else { + context.insert(PersistentHost(host)) + } + try commitMutation() + } + + public func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact { + let hosts = try context.fetch(FetchDescriptor()) + let snippets = try context.fetch(FetchDescriptor()) + let rules = try forwardingRules(for: hostID) + return HostDeletionImpact( + jumpHostReferences: hosts.filter { $0.jumpHostID == hostID }.count, + snippetReferences: snippets.filter { $0.targetHostIDs.contains(hostID) }.count, + forwardingRules: rules.count + ) + } + + /// The deletion marker, all durable side effects, and the revision become + /// visible in one database commit. A crash can therefore see either the + /// live host or a complete deletion saga, never half of each. + public func requestHostDeletion(_ hostID: Host.ID) throws { + guard try persistentHost(hostID) != nil else { return } + if try pendingDeletion(hostID) != nil { return } + + let ruleIDs = try forwardingRules(for: hostID).map(\.id) + let payload = try JSONEncoder().encode( + HostDeletionPayload(hostID: hostID, forwardingRuleIDs: ruleIDs) + ) + let eventID = UUID() + let now = Date() + context.insert( + PersistentPendingDeletion( + entityType: "host", entityID: hostID, createdAt: now + ) + ) + for handlerID in HostDeletionOutbox.handlerIDs { + context.insert( + PersistentOutboxJob( + OutboxJob( + eventID: eventID, handlerID: handlerID, + eventType: HostDeletionOutbox.eventType, payload: payload, + createdAt: now, nextAttemptAt: now + ) + ) + ) + } + try commitMutation() + } + + public func clearReferencesForDeletedHost(_ hostID: Host.ID) throws { + for host in try context.fetch(FetchDescriptor()) + where host.jumpHostID == hostID { + host.jumpHostID = nil + } + for snippet in try context.fetch(FetchDescriptor()) + where snippet.targetHostIDs.contains(hostID) { + snippet.targetHostIDs.removeAll { $0 == hostID } + } + for rule in try forwardingRules(for: hostID) { context.delete(rule) } + try context.delete( + model: PersistentCommandHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) + try context.delete( + model: PersistentSFTPHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) + try context.delete( + model: PersistentConnectionHistoryEntry.self, + where: #Predicate { $0.hostID == hostID } + ) + try commitMutation() + } + + public func finalizeHostDeletion(_ hostID: Host.ID) throws { + if let host = try persistentHost(hostID) { context.delete(host) } + if let marker = try pendingDeletion(hostID) { context.delete(marker) } + try commitMutation() + } + + private func publishMutation(reason: RepositoryChangeReason) throws { + let revision = try advanceRevision() + let current = try snapshot() + let change = RepositoryChange( + revision: revision, value: current.value, reason: reason + ) + for continuation in continuations.values { continuation.yield(change) } + } + + private func commitMutation() throws { + do { + let revision = try advanceRevisionWithoutSaving() + let vaultMutations = try HostPersistence(context: context).recordExternalMutation() + try context.save() + if !vaultMutations.isEmpty { didRecordVaultChanges?(vaultMutations) } + try publishCommittedMutation(revision: revision) + } catch { + context.rollback() + throw error + } + } + + private func publishCommittedMutation(revision: Int64) throws { + let current = try snapshot() + let change = RepositoryChange( + revision: revision, value: current.value, reason: .mutation + ) + for continuation in continuations.values { continuation.yield(change) } + } + + private func persistentHost(_ id: Host.ID) throws -> PersistentHost? { + var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func pendingDeletion(_ hostID: Host.ID) throws -> PersistentPendingDeletion? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.entityType == "host" && $0.entityID == hostID } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func forwardingRules( + for hostID: Host.ID + ) throws -> [PersistentPortForwardingRule] { + try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.hostID == hostID } + ) + ) + } + + private func currentRevision() throws -> Int64 { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.key == "repository" } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first?.revision ?? 0 + } + + private func advanceRevision() throws -> Int64 { + let revision = try advanceRevisionWithoutSaving() + try context.save() + return revision + } + + private func advanceRevisionWithoutSaving() throws -> Int64 { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.key == "repository" } + ) + descriptor.fetchLimit = 1 + let metadata: PersistentDatabaseMetadata + if let existing = try context.fetch(descriptor).first { + metadata = existing + } else { + metadata = PersistentDatabaseMetadata() + context.insert(metadata) + } + metadata.revision &+= 1 + return metadata.revision + } +} diff --git a/Frameworks/TermindPersistence/SwiftDataOutboxStore.swift b/Frameworks/TermindPersistence/SwiftDataOutboxStore.swift new file mode 100644 index 00000000..275ef593 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataOutboxStore.swift @@ -0,0 +1,84 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +@MainActor +public final class SwiftDataOutboxStore: OutboxStore { + private let context: ModelContext + + public init(context: ModelContext) { + self.context = context + } + + public func enqueue(_ jobs: [OutboxJob]) throws { + for job in jobs { context.insert(PersistentOutboxJob(job)) } + try save() + } + + public func nextReady(at date: Date) throws -> OutboxJob? { + let pending = OutboxJobStatus.pending.rawValue + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.status == pending && $0.nextAttemptAt <= date }, + sortBy: [SortDescriptor(\.createdAt), SortDescriptor(\.handlerID)] + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first?.value + } + + public func markRunning(_ id: OutboxJob.ID) throws { + guard let job = try persistentJob(id) else { return } + job.status = OutboxJobStatus.running.rawValue + job.attemptCount += 1 + job.lastError = nil + try save() + } + + public func markSucceeded(_ id: OutboxJob.ID) throws { + guard let job = try persistentJob(id) else { return } + job.status = OutboxJobStatus.succeeded.rawValue + job.lastError = nil + try save() + } + + public func markFailed(_ id: OutboxJob.ID, error: String, retryAt: Date?) throws { + guard let job = try persistentJob(id) else { return } + job.status = (retryAt == nil ? OutboxJobStatus.failed : .pending).rawValue + job.lastError = error + if let retryAt { job.nextAttemptAt = retryAt } + try save() + } + + public func resetInterruptedJobs() throws { + let running = OutboxJobStatus.running.rawValue + let jobs = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.status == running }) + ) + for job in jobs { job.status = OutboxJobStatus.pending.rawValue } + if !jobs.isEmpty { try save() } + } + + public func retryFailedJob(_ id: OutboxJob.ID) throws { + guard let job = try persistentJob(id), + job.status == OutboxJobStatus.failed.rawValue + else { return } + job.status = OutboxJobStatus.pending.rawValue + job.nextAttemptAt = Date() + job.lastError = nil + try save() + } + + private func persistentJob(_ id: UUID) throws -> PersistentOutboxJob? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.id == id } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func save() throws { + try context.savePendingChanges() + } +} + +public typealias SharedOutboxStore = SwiftDataOutboxStore diff --git a/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift new file mode 100644 index 00000000..152b2781 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataVaultJournal.swift @@ -0,0 +1,394 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +public nonisolated enum VaultSyncPersistenceError: LocalizedError { + case invalidEnvelope(String) + case unsupportedFormat(Int) + + public var errorDescription: String? { + switch self { + case .invalidEnvelope(let recordName): + String(localized: "The iCloud Vault record \(recordName) is invalid.") + case .unsupportedFormat(let version): + String(localized: "This Vault record requires sync format \(version).") + } + } +} + +public nonisolated struct VaultRemoteMerge: Sendable { + public var accepted: [VaultEnvelope] = [] + public var needsUpload: Set = [] +} + +public nonisolated struct VaultOutgoingEntry: Sendable { + public var envelope: VaultEnvelope + public var systemFieldsData: Data? + public init(envelope: VaultEnvelope, systemFieldsData: Data? = nil) { + self.envelope = envelope + self.systemFieldsData = systemFieldsData + } +} + +extension VaultSnapshot { + public nonisolated var recordPayloads: [VaultRecordKey: VaultRecordPayload] { + var result: [VaultRecordKey: VaultRecordPayload] = [:] + for value in hosts { result[VaultRecordKey(kind: .host, id: value.id)] = .host(value) } + for value in groups { + result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value) + } + for value in identities { + result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value) + } + for value in keys { + result[VaultRecordKey(kind: .sshKey, id: value.id)] = .sshKey(value) + } + for value in knownHosts { + result[VaultRecordKey(kind: .knownHost, id: value.id)] = .knownHost(value) + } + for value in snippets { + result[VaultRecordKey(kind: .snippet, id: value.id)] = .snippet(value) + } + for value in snippetPackages { + result[VaultRecordKey(kind: .snippetPackage, id: value.id)] = .snippetPackage(value) + } + for value in portForwardings { + result[VaultRecordKey(kind: .portForwarding, id: value.id)] = .portForwarding(value) + } + return result + } + + public nonisolated mutating func apply(_ envelope: VaultEnvelope) throws { + guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { + throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) + } + guard envelope.isValid else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + + if envelope.isDeleted { + remove(envelope.key) + return + } + guard let payload = envelope.payload else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + switch payload { + case .host(let value): upsert(value, in: &hosts) + case .hostGroup(let value): upsert(value, in: &groups) + case .identity(let value): upsert(value, in: &identities) + case .sshKey(let value): upsert(value, in: &keys) + case .knownHost(let value): upsert(value, in: &knownHosts) + case .snippet(let value): upsert(value, in: &snippets) + case .snippetPackage(let value): upsert(value, in: &snippetPackages) + case .portForwarding(let value): upsert(value, in: &portForwardings) + } + } + + private nonisolated mutating func remove(_ key: VaultRecordKey) { + switch key.kind { + case .host: hosts.removeAll { $0.id == key.id } + case .hostGroup: groups.removeAll { $0.id == key.id } + case .identity: identities.removeAll { $0.id == key.id } + case .sshKey: keys.removeAll { $0.id == key.id } + case .knownHost: knownHosts.removeAll { $0.id == key.id } + case .snippet: snippets.removeAll { $0.id == key.id } + case .snippetPackage: snippetPackages.removeAll { $0.id == key.id } + case .portForwarding: portForwardings.removeAll { $0.id == key.id } + } + } + + private nonisolated func upsert( + _ value: Value, in values: inout [Value] + ) where Value.ID == UUID { + if let index = values.firstIndex(where: { $0.id == value.id }) { + values[index] = value + } else { + values.append(value) + } + } +} + +@MainActor +public final class VaultSyncJournal { + private let context: ModelContext + + public init(context: ModelContext) { self.context = context } + + public func state() throws -> PersistentVaultSyncState { + var descriptor = FetchDescriptor() + descriptor.fetchLimit = 1 + if let existing = try context.fetch(descriptor).first { return existing } + let state = PersistentVaultSyncState() + context.insert(state) + return state + } + + @discardableResult + public func bootstrapIfNeeded(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { + let state = try state() + guard !state.bootstrapCompleted else { return .empty } + let existing = try entriesByName() + var changed = Set() + for payload in snapshot.recordPayloads.values { + let key = payload.key + guard existing[key.recordName] == nil else { continue } + let envelope = VaultEnvelope( + key: key, version: .baseline(writerID: state.installationID), payload: payload + ) + context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)) + changed.insert(key) + } + state.bootstrapCompleted = true + return VaultMutationSet(keys: changed) + } + + public func recordLocalChanges( + from oldSnapshot: VaultSnapshot, to newSnapshot: VaultSnapshot, + now: Date = .now + ) throws -> VaultMutationSet { + let state = try state() + let old = oldSnapshot.recordPayloads + let new = newSnapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in new where old[key] != payload { + let row = entries[key.recordName] + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + + for key in old.keys where new[key] == nil { + let row = entries[key.recordName] + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + isDeleted: true + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + return VaultMutationSet(keys: changed) + } + + public func pendingEnvelopes() throws -> [VaultEnvelope] { + try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.needsUpload } + ) + ).compactMap(\.envelope) + } + + public func outgoingEntry(for key: VaultRecordKey) throws -> VaultOutgoingEntry? { + guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { + return nil + } + return VaultOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData) + } + + public func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? { + try entry(recordName: key.recordName)?.envelope + } + + public func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge { + var result = VaultRemoteMerge() + let entries = try entriesByName() + for envelope in remote { + guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { + throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) + } + guard envelope.isValid else { + throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) + } + guard let local = entries[envelope.key.recordName]?.envelope else { + result.accepted.append(envelope) + continue + } + if local.version < envelope.version { + result.accepted.append(envelope) + } else if envelope.version < local.version { + result.needsUpload.insert(local.key) + } else if local != envelope { + let localData = VaultSyncCodec.encode(local) + let remoteData = VaultSyncCodec.encode(envelope) + if localData.lexicographicallyPrecedes(remoteData) { + result.accepted.append(envelope) + } else { + result.needsUpload.insert(local.key) + } + } + } + return result + } + + public func acceptRemote(_ envelopes: [VaultEnvelope]) throws { + var entries = try entriesByName() + for envelope in envelopes { + try store(envelope, needsUpload: false, in: &entries) + } + } + + public func markNeedsUpload(_ keys: Set) throws { + let entries = try entriesByName() + for key in keys { entries[key.recordName]?.needsUpload = true } + } + + public func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws { + guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { + return + } + row.needsUpload = false + } + + public func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws { + try entry(recordName: key.recordName)?.systemFieldsData = data + } + + public func quarantine(recordName: String, payloadData: Data, reason: String) throws { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName } + ) + descriptor.fetchLimit = 1 + if let row = try context.fetch(descriptor).first { + row.payloadData = payloadData + row.reason = reason + row.receivedAt = .now + } else { + context.insert( + PersistentVaultSyncQuarantine( + recordName: recordName, payloadData: payloadData, reason: reason + )) + } + } + + public func clearQuarantine(recordName: String) throws { + try context.delete( + model: PersistentVaultSyncQuarantine.self, + where: #Predicate { $0.recordName == recordName } + ) + } + + public func firstQuarantineReason() throws -> String? { + var descriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first?.reason + } + + public func recordCurrentSnapshot(_ snapshot: VaultSnapshot, now: Date = .now) throws + -> VaultMutationSet + { + let state = try state() + let current = snapshot.recordPayloads + var entries = try entriesByName() + var changed = Set() + + for (key, payload) in current { + let row = entries[key.recordName] + guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { + continue + } + let envelope = VaultEnvelope( + key: key, + version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), + payload: payload + ) + try store(envelope, needsUpload: true, in: &entries) + changed.insert(key) + } + + for row in entries.values { + guard let envelope = row.envelope, !envelope.isDeleted, + current[envelope.key] == nil + else { continue } + let tombstone = VaultEnvelope( + key: envelope.key, + version: .next(after: envelope.version, writerID: state.installationID, now: now), + isDeleted: true + ) + row.apply(tombstone, needsUpload: true) + changed.insert(envelope.key) + } + return VaultMutationSet(keys: changed) + } + + public func markAllLiveRecordsNeedsUploadForNewAccount() throws { + for row in try context.fetch(FetchDescriptor()) { + guard let envelope = row.envelope else { continue } + if envelope.isDeleted { + context.delete(row) + } else { + row.needsUpload = true + row.systemFieldsData = nil + } + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + public func markAllEntriesNeedsUploadAfterZoneReset() throws { + for row in try context.fetch(FetchDescriptor()) { + row.needsUpload = true + row.systemFieldsData = nil + } + let state = try state() + state.engineStateData = nil + state.lastSuccessfulSync = nil + } + + /// CloudKit record system fields include environment-specific change tags. + /// Keep every local envelope, but discard those tags and the serialized + /// engine state before moving between Development and Production. + public func markAllEntriesNeedsUploadAfterEnvironmentChange() throws { + try markAllEntriesNeedsUploadAfterZoneReset() + } + + private func entry(recordName: String) throws -> PersistentVaultSyncEntry? { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.recordName == recordName } + ) + descriptor.fetchLimit = 1 + return try context.fetch(descriptor).first + } + + private func store( + _ envelope: VaultEnvelope, needsUpload: Bool, + in entries: inout [String: PersistentVaultSyncEntry] + ) throws { + if let row = entries[envelope.key.recordName] { + row.apply(envelope, needsUpload: needsUpload) + } else { + let row = try PersistentVaultSyncEntry( + envelope: envelope, needsUpload: needsUpload + ) + context.insert(row) + entries[envelope.key.recordName] = row + } + } + + private func entriesByName() throws -> [String: PersistentVaultSyncEntry] { + Dictionary( + try context.fetch(FetchDescriptor()).map { + ($0.recordName, $0) + }, + uniquingKeysWith: { first, duplicate in + if let firstVersion = first.envelope?.version, + let duplicateVersion = duplicate.envelope?.version, + firstVersion < duplicateVersion + { + return duplicate + } + return first + } + ) + } +} diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift new file mode 100644 index 00000000..64e9e5a0 --- /dev/null +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -0,0 +1,143 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain + +@MainActor +public final class HostPersistence { + public static let schema = TermindPersistence.schema + + public let context: ModelContext + + public init(context: ModelContext) { + self.context = context + } + + public func load() throws -> VaultSnapshot { + let snapshot = try fetchSnapshot() + _ = try VaultSyncJournal(context: context).bootstrapIfNeeded(snapshot) + if context.hasChanges { try context.save() } + return snapshot + } + + private func fetchSnapshot() throws -> VaultSnapshot { + let hosts = try context.fetch(FetchDescriptor()).map(\.value) + let groups = try context.fetch(FetchDescriptor()).map(\.value) + let identities = try context.fetch(FetchDescriptor()).map(\.value) + let keys = try context.fetch(FetchDescriptor()).map(\.value) + let knownHosts = try context.fetch(FetchDescriptor()).map(\.value) + let snippets = try context.fetch(FetchDescriptor()).map(\.value) + let snippetPackages = try context.fetch(FetchDescriptor()).map(\.value) + let portForwardings = try context.fetch(FetchDescriptor()).map(\.value) + return VaultSnapshot( + hosts: hosts, groups: groups, identities: identities, keys: keys, + knownHosts: knownHosts, snippets: snippets, + snippetPackages: snippetPackages, portForwardings: portForwardings + ) + } + + public func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { + do { + let oldSnapshot = try fetchSnapshot() + let journal = VaultSyncJournal(context: context) + _ = try journal.bootstrapIfNeeded(oldSnapshot) + let mutations = try journal.recordLocalChanges(from: oldSnapshot, to: snapshot) + try persist(snapshot) + try context.save() + return mutations + } catch { + context.rollback() + throw error + } + } + + public func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { + do { + let journal = VaultSyncJournal(context: context) + let decision = try journal.mergeDecision(for: envelopes) + var snapshot = try fetchSnapshot() + for envelope in decision.accepted { try snapshot.apply(envelope) } + try persist(snapshot) + try journal.acceptRemote(decision.accepted) + try journal.markNeedsUpload(decision.needsUpload) + try context.save() + return VaultRemoteApplyResult( + snapshot: snapshot, + acceptedKeys: Set(decision.accepted.map(\.key)), + needsUpload: decision.needsUpload + ) + } catch { + context.rollback() + throw error + } + } + + public func recordExternalMutation() throws -> VaultMutationSet { + let snapshot = try fetchSnapshot() + let journal = VaultSyncJournal(context: context) + _ = try journal.bootstrapIfNeeded(snapshot) + return try journal.recordCurrentSnapshot(snapshot) + } + + private func persist(_ snapshot: VaultSnapshot) throws { + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.hosts, + rowID: \.id, valueID: \.id, + make: PersistentHost.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.groups, + rowID: \.id, valueID: \.id, + make: PersistentHostGroup.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.identities, + rowID: \.id, valueID: \.id, + make: PersistentIdentity.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.keys, + rowID: \.id, valueID: \.id, + make: PersistentSSHKey.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.knownHosts, + rowID: \.id, valueID: \.id, + make: PersistentKnownHost.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), with: snapshot.snippets, + rowID: \.id, valueID: \.id, + make: PersistentSnippet.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), + with: snapshot.snippetPackages, rowID: \.id, valueID: \.id, + make: PersistentSnippetPackage.init, apply: { $0.apply($1) } + ) + try reconcile( + try context.fetch(FetchDescriptor()), + with: snapshot.portForwardings, rowID: \.id, valueID: \.id, + make: PersistentPortForwardingRule.init, apply: { $0.apply($1) } + ) + } + + private func reconcile( + _ rows: [Model], with values: [Value], + rowID: (Model) -> UUID, valueID: (Value) -> UUID, + make: (Value) -> Model, apply: (Model, Value) -> Void + ) throws { + var byID: [UUID: Model] = [:] + for row in rows { + let id = rowID(row) + if byID[id] == nil { byID[id] = row } else { context.delete(row) } + } + let wanted = Set(values.map(valueID)) + for row in byID.values where !wanted.contains(rowID(row)) { context.delete(row) } + for value in values { + if let row = byID[valueID(value)] { apply(row, value) } else { context.insert(make(value)) } + } + } +} + +extension HostPersistence: VaultPersisting {} diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift new file mode 100644 index 00000000..5ab3c366 --- /dev/null +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -0,0 +1,49 @@ +import SwiftUI +import TermindDomain + +import struct TermindDomain.Host + +/// The same connection form inside a desktop work column or a mobile sheet. +public struct HostConnectionFields: View { + @Binding private var host: Host + + public init(host: Binding) { _host = host } + + public var body: some View { + Section("Connection") { + LabeledContent("Name") { + TextField("Name", text: $host.name, prompt: Text(host.host)).labelsHidden() + } + LabeledContent("Host") { + TextField("Host", text: $host.host, prompt: Text("IP address or hostname")) + .labelsHidden() + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + #endif + } + LabeledContent("Port") { + TextField("Port", value: $host.port, format: .number.grouping(.never)) + .labelsHidden() + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + } + Section("SFTP") { + LabeledContent("Initial Path") { + TextField("Initial Path", text: $host.sftpSessionInitialPath, prompt: Text("~")) + .labelsHidden() + .font(.body.monospaced()) + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + } + Text("Leave blank for the remote home directory, or enter an absolute path.") + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/Frameworks/TermindPresentation/HostSystemIcon.swift b/Frameworks/TermindPresentation/HostSystemIcon.swift new file mode 100644 index 00000000..b799b72f --- /dev/null +++ b/Frameworks/TermindPresentation/HostSystemIcon.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// The same system artwork in a host row and a workspace's Live Activity link. +public struct HostSystemIcon: View { + private let asset: String? + private let size: CGFloat + private let showsBackground: Bool + + public init(asset: String?, size: CGFloat = 32, showsBackground: Bool = true) { + self.asset = asset + self.size = size + self.showsBackground = showsBackground + } + + public var body: some View { + Group { + if let asset { + Image(asset, bundle: .main).resizable().scaledToFit() + } else { + Image(systemName: "server.rack").resizable().scaledToFit() + } + } + .foregroundStyle(.tint) + .padding(showsBackground ? size * 0.2 : 0) + .frame(width: size, height: size) + .background(.tint.opacity(showsBackground ? 0.16 : 0), in: RoundedRectangle(cornerRadius: size * 0.25)) + .accessibilityHidden(true) + } +} diff --git a/Frameworks/TermindPresentation/LinuxDistributionIconCatalog.swift b/Frameworks/TermindPresentation/LinuxDistributionIconCatalog.swift new file mode 100644 index 00000000..752730d3 --- /dev/null +++ b/Frameworks/TermindPresentation/LinuxDistributionIconCatalog.swift @@ -0,0 +1,36 @@ +import Foundation +import TermindDomain + +public nonisolated struct LinuxDistributionIconEntry: Codable, Hashable, Sendable { + public var title: String + public var asset: String + public var ids: [String] + public var hex: String +} + +public nonisolated struct LinuxDistributionIconCatalog: Codable, Sendable { + public var entries: [LinuxDistributionIconEntry] + + private static let bundled: Self? = { + guard + let url = Bundle.main.url( + forResource: "LinuxDistributionIcons", withExtension: "json" + ), let data = try? Data(contentsOf: url) + else { return nil } + return try? JSONDecoder().decode(Self.self, from: data) + }() + + public static func entry(for distribution: DetectedDistribution?) -> LinuxDistributionIconEntry? { + guard let distribution, let catalog = bundled else { return nil } + return catalog.entry(matching: distribution) + } + + public func entry(matching distribution: DetectedDistribution) -> LinuxDistributionIconEntry? { + let byID = Dictionary( + entries.flatMap { entry in entry.ids.map { ($0.lowercased(), entry) } }, + uniquingKeysWith: { first, _ in first } + ) + if let exact = byID[distribution.id] { return exact } + return distribution.likeIDs.lazy.compactMap { byID[$0] }.first + } +} diff --git a/Frameworks/TermindPresentation/MetricsSummaryView.swift b/Frameworks/TermindPresentation/MetricsSummaryView.swift new file mode 100644 index 00000000..16caf776 --- /dev/null +++ b/Frameworks/TermindPresentation/MetricsSummaryView.swift @@ -0,0 +1,22 @@ +import SwiftUI +import TermindDomain + +public struct MetricsSummaryView: View { + private let point: MetricsPoint + public init(point: MetricsPoint) { self.point = point } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let cpu = point.cpu { + LabeledContent("CPU", value: cpu.formatted(.number.precision(.fractionLength(1))) + "%") + ProgressView(value: min(1, max(0, cpu / 100))) + } + if let memory = point.memory { + LabeledContent("Memory", value: memory.formatted(.number.precision(.fractionLength(1))) + "%") + ProgressView(value: min(1, max(0, memory / 100))) + } + LabeledContent("Updated", value: point.date.formatted(date: .omitted, time: .standard)) + .font(.caption).foregroundStyle(.secondary) + } + } +} diff --git a/Frameworks/TermindPresentation/PortForwardingFields.swift b/Frameworks/TermindPresentation/PortForwardingFields.swift new file mode 100644 index 00000000..cdb048f5 --- /dev/null +++ b/Frameworks/TermindPresentation/PortForwardingFields.swift @@ -0,0 +1,66 @@ +import SwiftUI +import TermindDomain + +public struct PortForwardingFields: View { + @Binding private var rule: PortForwardingRule + public init(rule: Binding) { _rule = rule } + + public var body: some View { + Section("Port Forwarding") { + TextField("Name", text: $rule.label) + PortForwardingDirectionPicker(kind: $rule.kind) + Text(directionDescription).font(.caption).foregroundStyle(.secondary) + LabeledContent("Bind Address") { TextField("Bind Address", text: $rule.bindHost).labelsHidden() } + LabeledContent("Bind Port") { + TextField("Bind Port", value: $rule.bindPort, format: .number.grouping(.never)).labelsHidden() + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + if rule.kind != .dynamic { + LabeledContent("Target Host") { + TextField( + "Target Host", + text: Binding(get: { rule.targetHost ?? "localhost" }, set: { rule.targetHost = $0 }) + ).labelsHidden() + } + LabeledContent("Target Port") { + TextField( + "Target Port", value: Binding(get: { rule.targetPort ?? 80 }, set: { rule.targetPort = $0 }), + format: .number.grouping(.never) + ).labelsHidden() + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + } + } + #if os(iOS) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + #endif + } + private var directionDescription: String { + switch rule.kind { + case .local: String(localized: "This Device → SSH Host → Target") + case .remote: String(localized: "SSH Host → This Device → Target") + case .dynamic: String(localized: "SOCKS5 on This Device → SSH Host → Requested Target") + } + } + +} + +public struct PortForwardingDirectionPicker: View { + @Binding private var kind: PortForwardingRule.Kind + public init(kind: Binding) { _kind = kind } + public var body: some View { + Picker("Direction", selection: $kind) { + Text("Local").tag(PortForwardingRule.Kind.local).accessibilityLabel("Local Forward") + Text("Remote").tag(PortForwardingRule.Kind.remote).accessibilityLabel("Remote Forward") + Text("Dynamic").tag(PortForwardingRule.Kind.dynamic).accessibilityLabel("Dynamic SOCKS5 Proxy") + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(maxWidth: .infinity) + } +} diff --git a/Frameworks/TermindPresentation/UnixPermissionFields.swift b/Frameworks/TermindPresentation/UnixPermissionFields.swift new file mode 100644 index 00000000..2e613ad2 --- /dev/null +++ b/Frameworks/TermindPresentation/UnixPermissionFields.swift @@ -0,0 +1,86 @@ +import SwiftUI +import TermindDomain + +public struct UnixPermissionFields: View { + @Binding private var draft: SFTPPermissionDraft + public init(draft: Binding) { _draft = draft } + + public var body: some View { + Section("Permissions") { + TextField("Octal permissions", text: Binding(get: { draft.text }, set: { _ = draft.accept($0) })) + .font(.body.monospaced()) + #if os(iOS) + .keyboardType(.numberPad) + #endif + } + Section("File Access") { UnixPermissionGrid(draft: $draft) } + Section("Special Permissions") { + Toggle("Set UID", isOn: permission(0o4000)) + Toggle("Set GID", isOn: permission(0o2000)) + Toggle("Sticky Bit", isOn: permission(0o1000)) + } + } + + private func permission(_ mask: UInt32) -> Binding { + Binding(get: { draft.contains(mask) }, set: { draft.set(mask, enabled: $0) }) + } +} + +public struct UnixPermissionGrid: View { + @Binding private var draft: SFTPPermissionDraft + private let horizontalSpacing: CGFloat + private let verticalSpacing: CGFloat + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + public init(draft: Binding, horizontalSpacing: CGFloat = 16, verticalSpacing: CGFloat = 12) { + _draft = draft + self.horizontalSpacing = horizontalSpacing + self.verticalSpacing = verticalSpacing + } + + public var body: some View { + if dynamicTypeSize.isAccessibilitySize { + VStack(alignment: .leading, spacing: verticalSpacing) { + ForEach(0..<3) { row in + Text(title(row)).font(.headline) + toggle(row, bit: 4, label: String(localized: "Read"), hideLabel: false) + toggle(row, bit: 2, label: String(localized: "Write"), hideLabel: false) + toggle(row, bit: 1, label: String(localized: "Execute"), hideLabel: false) + } + } + } else { + Grid(horizontalSpacing: horizontalSpacing, verticalSpacing: verticalSpacing) { + GridRow { + Color.clear.frame(width: 72, height: 1) + Text("Read") + Text("Write") + Text("Execute") + }.font(.caption.weight(.semibold)).foregroundStyle(.secondary) + ForEach(0..<3) { row in + GridRow { + Text(title(row)).frame(width: 72, alignment: .leading).foregroundStyle(.secondary) + toggle(row, bit: 4, label: String(localized: "Read"), hideLabel: true) + toggle(row, bit: 2, label: String(localized: "Write"), hideLabel: true) + toggle(row, bit: 1, label: String(localized: "Execute"), hideLabel: true) + } + } + } + } + } + + @ViewBuilder private func toggle(_ row: Int, bit: UInt32, label: String, hideLabel: Bool) -> some View { + let mask = bit << ((2 - row) * 3) + let control = Toggle(label, isOn: Binding(get: { draft.contains(mask) }, set: { draft.set(mask, enabled: $0) })) + .accessibilityLabel("\(title(row)), \(label)") + .toggleStyle(.switch).controlSize(.small) + if hideLabel { control.labelsHidden() } else { control } + } + + private func title(_ index: Int) -> String { + switch index { + case 0: String(localized: "Owner") + case 1: String(localized: "Group") + default: String(localized: "Others") + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/DispatchQueue+Async.swift b/Frameworks/TermindRemoteInfrastructure/SSH/DispatchQueue+Async.swift new file mode 100644 index 00000000..3ddf39b4 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/DispatchQueue+Async.swift @@ -0,0 +1,22 @@ +import Foundation + +extension DispatchQueue { + func perform( + _ operation: @escaping @Sendable () throws -> T + ) async throws -> T { + try await withCheckedThrowingContinuation { continuation in + async { + do { continuation.resume(returning: try operation()) } catch { continuation.resume(throwing: error) } + } + } + } + + func perform(_ operation: @escaping @Sendable () -> Void) async { + await withCheckedContinuation { continuation in + async { + operation() + continuation.resume() + } + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift new file mode 100644 index 00000000..403c1ae3 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/PortForwardingController.swift @@ -0,0 +1,259 @@ +import Foundation +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated enum PortForwardingRuntimeState: Hashable, Sendable { + case stopped + case preparing + case awaitingTrust + case awaitingAuthentication + case connecting + case starting + case running(activeConnections: Int) + case reconnecting(attempt: Int, nextRetry: Date, message: String) + case failed(String) + + public var isActive: Bool { + switch self { + case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting, + .starting, .running, .reconnecting: + true + case .stopped, .failed: false + } + } + + public var label: String { + switch self { + case .stopped: String(localized: "Stopped") + case .preparing: String(localized: "Preparing") + case .awaitingTrust: String(localized: "Awaiting trust") + case .awaitingAuthentication: String(localized: "Awaiting authentication") + case .connecting: String(localized: "Connecting") + case .starting: String(localized: "Starting") + case .running(let count): + count == 0 + ? String(localized: "Running") + : count == 1 + ? String(localized: "Running · 1 connection") + : String(localized: "Running · \(count) connections") + case .reconnecting(let attempt, _, _): String(localized: "Reconnecting · attempt \(attempt)") + case .failed(let message): message + } + } +} + +/// Process-local intent and live resources. No state in this type is persisted, +/// which guarantees that launching Termind never silently exposes a saved port. +@Observable +public final class PortForwardingController { + private let hosts: HostLibraryModel + public private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:] + private var desired: Set = [] + private var sessions: [Host.ID: SSHForwardingSession] = [:] + private var connectionTasks: [Host.ID: Task] = [:] + private var reconnectTasks: [Host.ID: Task] = [:] + private var reconnectAttempts: [Host.ID: Int] = [:] + private var connectedAt: [Host.ID: Date] = [:] + + public init(hosts: HostLibraryModel) { self.hosts = hosts } + + public func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { + states[id] ?? .stopped + } + + public func isEnabled(_ id: PortForwardingRule.ID) -> Bool { desired.contains(id) } + + public func start(_ id: PortForwardingRule.ID) { + guard let rule = hosts.portForwarding(id), !desired.contains(id) else { return } + desired.insert(id) + states[id] = .preparing + Task { await start(rule) } + } + + public func start(_ ids: [PortForwardingRule.ID]) { ids.forEach(start) } + + public func stop(_ id: PortForwardingRule.ID) { + desired.remove(id) + states[id] = .stopped + guard let rule = hosts.portForwarding(id), let session = sessions[rule.hostID] else { return } + Task { + await session.stop(id) + await disconnectHostIfIdle(rule.hostID) + } + } + + public func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) } + + public func stopAll() { + let all = Array(desired) + desired.removeAll() + for id in all { states[id] = .stopped } + let active = sessions + sessions.removeAll() + for task in connectionTasks.values { task.cancel() } + connectionTasks.removeAll() + for task in reconnectTasks.values { task.cancel() } + reconnectTasks.removeAll() + reconnectAttempts.removeAll() + connectedAt.removeAll() + Task { for session in active.values { await session.disconnect() } } + } + + private func start(_ rule: PortForwardingRule) async { + do { + guard rule.isValid else { + throw TermindSSHError.route(String(localized: "The forwarding rule is incomplete.")) + } + guard let host = hosts.host(rule.hostID) else { + throw TermindSSHError.route(String(localized: "The forwarding host is unavailable.")) + } + let session: SSHForwardingSession + if let existing = sessions[host.id] { + session = existing + } else { + let created = makeSession(for: host.id) + sessions[host.id] = created + session = created + states[rule.id] = .connecting + let task = Task { + let configuration = try hosts.sshConnectionConfiguration(for: host) + try await created.connect(configuration) + } + connectionTasks[host.id] = task + do { try await task.value } catch { + sessions[host.id] = nil + connectionTasks[host.id] = nil + throw error + } + connectionTasks[host.id] = nil + connectedAt[host.id] = Date() + } + if let task = connectionTasks[host.id] { try await task.value } + guard desired.contains(rule.id) else { return } + states[rule.id] = .starting + try await session.start(rule) + guard desired.contains(rule.id) else { + await session.stop(rule.id) + return + } + states[rule.id] = .running(activeConnections: 0) + } catch { + if desired.contains(rule.id), + let sshError = error as? TermindSSHError, + sshError.isRetryableForwardingFailure + { + if let session = sessions.removeValue(forKey: rule.hostID) { + await session.disconnect() + } + if reconnectTasks[rule.hostID] == nil { + scheduleReconnect( + rule.hostID, attempt: 1, message: error.localizedDescription + ) + } + } else { + desired.remove(rule.id) + states[rule.id] = .failed(error.localizedDescription) + await disconnectHostIfIdle(rule.hostID) + } + } + } + + private func disconnectHostIfIdle(_ hostID: Host.ID) async { + let hasActiveRule = desired.contains { hosts.portForwarding($0)?.hostID == hostID } + guard !hasActiveRule else { return } + let session = sessions.removeValue(forKey: hostID) + connectionTasks[hostID]?.cancel() + connectionTasks[hostID] = nil + reconnectTasks[hostID]?.cancel() + reconnectTasks[hostID] = nil + reconnectAttempts[hostID] = nil + connectedAt[hostID] = nil + if let session { await session.disconnect() } + } + + private func makeSession(for hostID: Host.ID) -> SSHForwardingSession { + SSHForwardingSession( + connectionHandler: { [weak self] id, count in + Task { @MainActor in + guard let self, self.desired.contains(id) else { return } + self.states[id] = .running(activeConnections: count) + } + }, + failureHandler: { [weak self] message in + Task { @MainActor in self?.handleConnectionFailure(hostID, message: message) } + } + ) + } + + private func handleConnectionFailure(_ hostID: Host.ID, message: String) { + guard reconnectTasks[hostID] == nil else { return } + let ids = desiredRuleIDs(for: hostID) + guard !ids.isEmpty else { return } + sessions[hostID] = nil + connectionTasks[hostID]?.cancel() + connectionTasks[hostID] = nil + let wasStable = connectedAt[hostID].map { Date().timeIntervalSince($0) >= 60 } == true + let attempt = wasStable ? 1 : (reconnectAttempts[hostID] ?? 0) + 1 + scheduleReconnect(hostID, attempt: attempt, message: message) + } + + private func scheduleReconnect(_ hostID: Host.ID, attempt: Int, message: String) { + let delays: [TimeInterval] = [1, 2, 5, 10, 30] + let delay = delays[min(attempt - 1, delays.count - 1)] + let nextRetry = Date().addingTimeInterval(delay) + reconnectAttempts[hostID] = attempt + for id in desiredRuleIDs(for: hostID) { + states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message) + } + reconnectTasks[hostID] = Task { [weak self] in + do { try await Task.sleep(for: .seconds(delay)) } catch { return } + guard let self else { return } + await self.reconnect(hostID, attempt: attempt) + } + } + + private func reconnect(_ hostID: Host.ID, attempt: Int) async { + reconnectTasks[hostID] = nil + let ids = desiredRuleIDs(for: hostID) + guard !ids.isEmpty, let host = hosts.host(hostID) else { return } + let session = makeSession(for: hostID) + do { + let configuration = try hosts.sshConnectionConfiguration(for: host) + try await session.connect(configuration) + guard !desiredRuleIDs(for: hostID).isEmpty else { + await session.disconnect() + return + } + sessions[hostID] = session + connectedAt[hostID] = Date() + for id in desiredRuleIDs(for: hostID) { + guard let rule = hosts.portForwarding(id) else { continue } + states[id] = .starting + try await session.start(rule) + states[id] = .running(activeConnections: 0) + } + } catch { + sessions[hostID] = nil + await session.disconnect() + if let sshError = error as? TermindSSHError, + !sshError.isRetryableForwardingFailure + { + for id in desiredRuleIDs(for: hostID) { + desired.remove(id) + states[id] = .failed(error.localizedDescription) + } + reconnectAttempts[hostID] = nil + } else { + scheduleReconnect( + hostID, attempt: attempt + 1, message: error.localizedDescription + ) + } + } + } + + private func desiredRuleIDs(for hostID: Host.ID) -> [PortForwardingRule.ID] { + desired.filter { hosts.portForwarding($0)?.hostID == hostID } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift new file mode 100644 index 00000000..e3239a6a --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/RemoteConnectionCoordinator.swift @@ -0,0 +1,64 @@ +import Foundation +import TermindApplication + +/// Application-owned construction and one-shot operation boundary for all +/// SSH/SFTP transports. Features receive this service instead of constructing +/// infrastructure clients in views. +public nonisolated final class RemoteConnectionCoordinator: Sendable { + public init() {} + public func makeSSHSession( + output: @escaping SSHSessionActor.OutputHandler, + exit: @escaping SSHSessionActor.ExitHandler, + shellIntegration: @escaping SSHSessionActor.ShellIntegrationHandler = { _ in } + ) -> SSHSessionActor { + SSHSessionActor( + output: output, exit: exit, shellIntegration: shellIntegration + ) + } + + public func makeSFTPClient() -> any SFTPClientProtocol { SFTPClient() } + + public func makeExecSession() -> any SSHExecSessionProtocol { SSHExecSession() } + + /// Mirrors `connectSFTP`, including its guarantee that a session which fails + /// to come up is torn down here rather than handed back half-open. + public func connectExec( + using configuration: SSHConnectionConfiguration + ) async throws -> any SSHExecSessionProtocol { + let session = makeExecSession() + do { + try await session.connect(using: configuration) + return session + } catch { + await session.disconnect() + throw error + } + } + + public func connectSFTP( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler? = nil, + connectionFailure: (@Sendable (String) -> Void)? = nil + ) async throws -> any SFTPClientProtocol { + let client = makeSFTPClient() + do { + try await client.connect( + using: configuration, progress: progress, + connectionFailure: connectionFailure + ) + return client + } catch { + await client.disconnect() + throw error + } + } + + public func installAuthorizedKey( + _ publicKey: String, named keyName: String, + using configuration: SSHConnectionConfiguration + ) async throws { + try await SSHSessionActor.installAuthorizedKey( + publicKey, named: keyName, using: configuration + ) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift new file mode 100644 index 00000000..cafe8329 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SFTPClient.swift @@ -0,0 +1,1004 @@ +import CSSH2 +import Darwin +import Foundation +import TermindApplication +import TermindDomain + +public nonisolated struct RemoteFile: Identifiable, Hashable, Sendable { + public nonisolated enum Kind: String, Hashable, Sendable { + case file, directory, symbolicLink, special, unknown + + public var symbol: String { + switch self { + case .directory: "folder.fill" + case .symbolicLink: "link" + case .file: "doc" + case .special, .unknown: "questionmark.square.dashed" + } + } + } + + public var path: String + public var name: String + public var kind: Kind + public var size: Int64 + public var modifiedAt: Date? + public var permissions: UInt32 + public var ownerUID: UInt32 + public var ownerGID: UInt32 + + public var id: String { path } + public var isDirectory: Bool { kind == .directory } + public var isHidden: Bool { name.hasPrefix(".") } + + public var permissionMode: UInt32 { permissions & 0o7777 } + + public var sftpKindDescription: String { + switch kind { + case .directory: return String(localized: "Folder") + case .symbolicLink: return String(localized: "Symbolic Link") + case .special: return String(localized: "Special") + case .unknown: return String(localized: "Unknown") + case .file: + let fileExtension = (name as NSString).pathExtension + return fileExtension.isEmpty ? String(localized: "File") : fileExtension.lowercased() + } + } + + public var octalPermissionDescription: String { + String(format: "%04o", permissionMode) + } + + public var permissionDescription: String { + let type: Character = + switch kind { + case .file: "-" + case .directory: "d" + case .symbolicLink: "l" + case .special: "s" + case .unknown: "?" + } + let masks: [UInt32] = [0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001] + let letters: [Character] = ["r", "w", "x", "r", "w", "x", "r", "w", "x"] + return String([type] + zip(masks, letters).map { permissions & $0.0 == 0 ? "-" : $0.1 }) + } + public init( + path: String, name: String, kind: Kind, size: Int64, modifiedAt: Date? = nil, permissions: UInt32, + ownerUID: UInt32, ownerGID: UInt32 + ) { + self.path = path + self.name = name + self.kind = kind + self.size = size + self.modifiedAt = modifiedAt + self.permissions = permissions + self.ownerUID = ownerUID + self.ownerGID = ownerGID + } + +} + +public nonisolated final class TransferCancellationToken: @unchecked Sendable { + public init() {} + private let lock = NSLock() + private var cancelled = false + + public var isCancelled: Bool { lock.withLock { cancelled } } + public func cancel() { lock.withLock { cancelled = true } } +} + +public nonisolated struct SFTPProgress: Sendable { + public var completedBytes: Int64 + public var totalBytes: Int64 + public var bytesPerSecond: Int64 + public init(completedBytes: Int64, totalBytes: Int64, bytesPerSecond: Int64) { + self.completedBytes = completedBytes + self.totalBytes = totalBytes + self.bytesPerSecond = bytesPerSecond + } + +} + +nonisolated private struct SFTPProgressMeter { + public let total: Int64 + public let report: @Sendable (SFTPProgress) -> Void + public private(set) var completed: Int64 = 0 + private var sampledBytes: Int64 = 0 + private var sampledAt = ContinuousClock.now + + public nonisolated init( + total: Int64, report: @escaping @Sendable (SFTPProgress) -> Void + ) { + self.total = total + self.report = report + } + + public nonisolated mutating func add(_ bytes: Int) { + completed += Int64(bytes) + sampledBytes += Int64(bytes) + let elapsed = sampledAt.duration(to: .now) + guard elapsed >= .milliseconds(250) else { return } + let components = elapsed.components + let seconds = max( + 0.001, + Double(components.seconds) + Double(components.attoseconds) / 1e18 + ) + report( + SFTPProgress( + completedBytes: completed, totalBytes: total, + bytesPerSecond: Int64(Double(sampledBytes) / seconds) + )) + sampledBytes = 0 + sampledAt = .now + } + + public nonisolated func finish() { + report( + SFTPProgress( + completedBytes: completed, totalBytes: total, bytesPerSecond: 0 + )) + } +} + +public nonisolated protocol SFTPClientProtocol: Sendable { + func connect( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler? + ) async throws + func connect( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler?, + connectionFailure: (@Sendable (String) -> Void)? + ) async throws + func disconnect() async + func homeDirectory() async throws -> String + func listDirectory(at path: String) async throws -> [RemoteFile] + func attributes(at path: String) async throws -> RemoteFile? + func readFile(at path: String, maximumBytes: Int) async throws -> Data + func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws + func setPermissions(at path: String, to permissions: UInt32) async throws + func createDirectory(at path: String) async throws + func rename(from source: String, to destination: String, overwrite: Bool) async throws + func removeFile(at path: String) async throws + func removeDirectory(at path: String) async throws + func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws + func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws + func copyFile( + from sourcePath: String, + to destinationPath: String, + on destination: any SFTPClientProtocol, + byteCount: Int64, + overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws +} + +nonisolated extension SFTPClientProtocol { + public func connect( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler?, + connectionFailure: (@Sendable (String) -> Void)? + ) async throws { + try await connect(using: configuration, progress: progress) + } + + public func readFile(at path: String, maximumBytes: Int) async throws -> Data { + throw SFTPError.operation("This SFTP client does not support inline reads.") + } + + public func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { + throw SFTPError.operation("This SFTP client does not support inline writes.") + } + + public func copyFile( + from sourcePath: String, + to destinationPath: String, + on destination: any SFTPClientProtocol, + byteCount: Int64, + overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + throw SFTPError.operation("This SFTP client does not support streamed remote copying.") + } +} + +public nonisolated enum SFTPError: LocalizedError, Sendable { + case notConnected + case connectionLost(String) + case cancelled + case localFile(String) + case operation(String) + + public var errorDescription: String? { + switch self { + case .notConnected: String(localized: "The SFTP connection is not available.") + case .connectionLost(let message): message + case .cancelled: String(localized: "Transfer cancelled.") + case .localFile(let message), .operation(let message): message + } + } +} + +public actor SFTPClient: SFTPClientProtocol { + public init() {} + private nonisolated let worker = SFTPWorker() + + public func connect( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler? = nil + ) async throws { + try await worker.connect(using: configuration, progress: progress) + } + + public func connect( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler?, + connectionFailure: (@Sendable (String) -> Void)? + ) async throws { + try await worker.connect( + using: configuration, progress: progress, + connectionFailure: connectionFailure + ) + } + + public func disconnect() async { await worker.disconnect() } + + public func homeDirectory() async throws -> String { + try await worker.homeDirectory() + } + + public func listDirectory(at path: String) async throws -> [RemoteFile] { + try await worker.listDirectory(at: path) + } + + public func attributes(at path: String) async throws -> RemoteFile? { + try await worker.attributes(at: path) + } + + public func readFile(at path: String, maximumBytes: Int) async throws -> Data { + try await worker.readFile(at: path, maximumBytes: maximumBytes) + } + + public func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { + try await worker.writeFile(data, at: path, overwrite: overwrite) + } + + public func setPermissions(at path: String, to permissions: UInt32) async throws { + try await worker.setPermissions(at: path, to: permissions) + } + + public func createDirectory(at path: String) async throws { + try await worker.createDirectory(at: path) + } + + public func rename(from source: String, to destination: String, overwrite: Bool) async throws { + try await worker.rename(from: source, to: destination, overwrite: overwrite) + } + + public func removeFile(at path: String) async throws { try await worker.removeFile(at: path) } + public func removeDirectory(at path: String) async throws { try await worker.removeDirectory(at: path) } + + public func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await worker.uploadFile( + from: localURL, to: remotePath, overwrite: overwrite, + cancellation: cancellation, progress: progress + ) + } + + public func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await worker.downloadFile( + from: remotePath, to: localURL, overwrite: overwrite, + cancellation: cancellation, progress: progress + ) + } + + public func copyFile( + from sourcePath: String, + to destinationPath: String, + on destination: any SFTPClientProtocol, + byteCount: Int64, + overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + guard let destination = destination as? SFTPClient else { + throw SFTPError.operation("The destination cannot receive a streamed SFTP copy.") + } + let relay = try SFTPSocketRelay() + defer { relay.close() } + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + defer { relay.finishWriting() } + try await self.worker.downloadFile( + from: sourcePath, toSocket: relay.writer, + cancellation: cancellation + ) + } + group.addTask { + try await destination.worker.uploadFile( + fromSocket: relay.reader, to: destinationPath, + byteCount: byteCount, overwrite: overwrite, + cancellation: cancellation, progress: progress + ) + } + do { + while try await group.next() != nil {} + } catch { + cancellation.cancel() + relay.stop() + group.cancelAll() + throw error + } + } + } +} + +private nonisolated final class SFTPSocketRelay: @unchecked Sendable { + public let writer: Int32 + public let reader: Int32 + private let lock = NSLock() + private var closed = false + + public init() throws { + var pair: [Int32] = [-1, -1] + guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else { + throw SFTPError.operation("Unable to create the SFTP copy relay: \(String(cString: strerror(errno)))") + } + writer = pair[0] + reader = pair[1] + var yes: Int32 = 1 + for descriptor in pair { + _ = setsockopt( + descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes, + socklen_t(MemoryLayout.size) + ) + } + } + + public func finishWriting() { + lock.withLock { + guard !closed else { return } + _ = Darwin.shutdown(writer, SHUT_WR) + } + } + + public func stop() { + lock.withLock { + guard !closed else { return } + _ = Darwin.shutdown(writer, SHUT_RDWR) + _ = Darwin.shutdown(reader, SHUT_RDWR) + } + } + + public func close() { + lock.withLock { + guard !closed else { return } + closed = true + Darwin.close(writer) + Darwin.close(reader) + } + } +} + +private nonisolated final class SFTPWorker: @unchecked Sendable { + private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.sftp") + private var routeTransport: SSHRoutedSocket? + private var session: OpaquePointer? + private var sftp: OpaquePointer? + private var keepaliveTimer: (any DispatchSourceTimer)? + private var connectionFailure: (@Sendable (String) -> Void)? + private var didReportConnectionFailure = false + + deinit { cleanup() } + + public func connect( + using configuration: SSHConnectionConfiguration, + progress: SSHRouteProgressHandler?, + connectionFailure: (@Sendable (String) -> Void)? = nil + ) async throws { + try await queue.perform { + self.cleanup() + let transport = try SSHRoutedSocket.open( + host: configuration.host, port: configuration.port, + proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20, + progress: progress + ) + progress?(.connecting(configuration.hostID)) + self.routeTransport = transport + guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw TermindSSHError.library("Unable to create an SSH session.") + } + self.session = session + libssh2_session_set_blocking(session, 1) + guard libssh2_session_handshake(session, transport.socket) == 0 else { + throw SSHSessionWorker.sessionError(session, prefix: "SSH handshake failed") + } + let observed = try SSHSessionWorker.observation( + host: configuration.host, port: configuration.port, session: session + ) + try SSHSessionWorker.validateHostKey( + observed, expected: configuration.expectedHostKeys + ) + progress?(.authenticating(configuration.hostID)) + try SSHSessionWorker.authenticate( + username: configuration.username, + credentials: configuration.credentials, + session: session + ) + progress?(.authenticated(configuration.hostID)) + progress?(.initializing(configuration.hostID, String(localized: "Starting SFTP subsystem"))) + guard let sftp = libssh2_sftp_init(session) else { + throw SSHSessionWorker.sessionError(session, prefix: "Unable to start SFTP") + } + self.sftp = sftp + self.connectionFailure = connectionFailure + self.didReportConnectionFailure = false + self.startKeepalive(for: session) + } + } + + public func disconnect() async { await queue.perform { self.cleanup() } } + + public func homeDirectory() async throws -> String { + try await queue.perform { + let sftp = try self.requireSFTP() + let source = "." + var target = [CChar](repeating: 0, count: 4 * 1024) + let count = source.withCString { sourceBuffer in + libssh2_sftp_symlink_ex( + sftp, sourceBuffer, UInt32(source.utf8.count), + &target, UInt32(target.count), Int32(LIBSSH2_SFTP_REALPATH) + ) + } + guard count > 0 else { throw self.error("Unable to resolve the remote home directory") } + let bytes = target.prefix(Int(count)).map { UInt8(bitPattern: $0) } + return RemotePath.normalized(String(decoding: bytes, as: UTF8.self)) + } + } + + public func listDirectory(at path: String) async throws -> [RemoteFile] { + try await queue.perform { + let sftp = try self.requireSFTP() + guard + let handle = path.withCString({ + libssh2_sftp_open_ex( + sftp, $0, UInt32(path.utf8.count), 0, 0, + Int32(LIBSSH2_SFTP_OPENDIR) + ) + }) + else { + throw self.error("Unable to open \(path)") + } + defer { _ = libssh2_sftp_close_handle(handle) } + var result: [RemoteFile] = [] + var name = [CChar](repeating: 0, count: 4 * 1024) + while true { + var attributes = LIBSSH2_SFTP_ATTRIBUTES() + let count = libssh2_sftp_readdir_ex( + handle, &name, name.count, nil, 0, &attributes + ) + if count > 0 { + let bytes = name.prefix(Int(count)).map { UInt8(bitPattern: $0) } + let filename = String(decoding: bytes, as: UTF8.self) + guard filename != ".", filename != ".." else { continue } + result.append( + Self.remoteFile(path: Self.join(path, filename), name: filename, attributes: attributes)) + } else if count == 0 { + break + } else { + throw self.error("Unable to read \(path)") + } + } + return result.sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory } + return $0.name.localizedStandardCompare($1.name) == .orderedAscending + } + } + } + + public func attributes(at path: String) async throws -> RemoteFile? { + try await queue.perform { + let sftp = try self.requireSFTP() + var attributes = LIBSSH2_SFTP_ATTRIBUTES() + let result = path.withCString { + libssh2_sftp_stat_ex(sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_LSTAT), &attributes) + } + if result == 0 { + return Self.remoteFile( + path: path, name: URL(fileURLWithPath: path).lastPathComponent, + attributes: attributes + ) + } + if libssh2_sftp_last_error(sftp) == LIBSSH2_FX_NO_SUCH_FILE { return nil } + throw self.error("Unable to inspect \(path)") + } + } + + public func readFile(at path: String, maximumBytes: Int) async throws -> Data { + try await queue.perform { + guard maximumBytes > 0 else { return Data() } + return try self.withRemoteReadHandle(at: path) { handle in + var data = Data() + var buffer = [CChar](repeating: 0, count: min(64 * 1024, maximumBytes + 1)) + while data.count <= maximumBytes { + let wanted = min(buffer.count, maximumBytes + 1 - data.count) + let count = libssh2_sftp_read(handle, &buffer, wanted) + if count > 0 { + data.append(Data(bytes: buffer, count: count)) + } else if count == 0 { + break + } else { + throw self.error("Unable to read \(path)") + } + } + guard data.count <= maximumBytes else { + throw SFTPError.operation("\(path) exceeds the inline read limit of \(maximumBytes) bytes.") + } + return data + } + } + } + + public func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + if !overwrite { + var attributes = LIBSSH2_SFTP_ATTRIBUTES() + let exists = path.withCString { + libssh2_sftp_stat_ex( + sftp, $0, UInt32(path.utf8.count), + Int32(LIBSSH2_SFTP_LSTAT), &attributes + ) == 0 + } + if exists { throw SFTPError.operation("\(path) already exists.") } + } + try self.withAtomicRemoteWrite( + sftp: sftp, destination: path, overwrite: overwrite, + temporaryTag: ".termind-agent-part-" + ) { handle in + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { + return + } + var offset = 0 + while offset < bytes.count { + let written = libssh2_sftp_write( + handle, base.advanced(by: offset), bytes.count - offset + ) + guard written > 0 else { throw self.error("Unable to write \(path)") } + offset += written + } + } + } + } + } + + public func setPermissions(at path: String, to permissions: UInt32) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + var attributes = LIBSSH2_SFTP_ATTRIBUTES() + attributes.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) + attributes.permissions = UInt(permissions & 0o7777) + let result = path.withCString { + libssh2_sftp_stat_ex( + sftp, $0, UInt32(path.utf8.count), + Int32(LIBSSH2_SFTP_SETSTAT), &attributes + ) + } + guard result == 0 else { + throw self.error("Unable to change permissions for \(path)") + } + } + } + + public func createDirectory(at path: String) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + let result = path.withCString { + libssh2_sftp_mkdir_ex(sftp, $0, UInt32(path.utf8.count), 0o755) + } + guard result == 0 else { throw self.error("Unable to create \(path)") } + } + } + + public func rename(from source: String, to destination: String, overwrite: Bool) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 + let result = source.withCString { sourceBuffer in + destination.withCString { destinationBuffer in + libssh2_sftp_rename_ex( + sftp, sourceBuffer, UInt32(source.utf8.count), + destinationBuffer, UInt32(destination.utf8.count), flags + ) + } + } + guard result == 0 else { throw self.error("Unable to rename \(source)") } + } + } + + public func removeFile(at path: String) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + guard + path.withCString({ + libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count)) + }) == 0 + else { + throw self.error("Unable to delete \(path)") + } + } + } + + public func removeDirectory(at path: String) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + guard + path.withCString({ + libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count)) + }) == 0 + else { + throw self.error("Unable to delete \(path)") + } + } + } + + public func uploadFile( + from localURL: URL, to remotePath: String, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + let file: FileHandle + do { file = try FileHandle(forReadingFrom: localURL) } catch { + throw SFTPError.localFile(error.localizedDescription) + } + defer { try? file.close() } + try self.withAtomicRemoteWrite( + sftp: sftp, destination: remotePath, overwrite: overwrite + ) { handle in + let total = + (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) + .map(Int64.init) ?? 0 + var meter = SFTPProgressMeter(total: total, report: progress) + while true { + if cancellation.isCancelled { throw SFTPError.cancelled } + let data = try file.read(upToCount: 64 * 1024) ?? Data() + if data.isEmpty { break } + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) + else { return } + var offset = 0 + while offset < bytes.count { + if cancellation.isCancelled { throw SFTPError.cancelled } + let written = libssh2_sftp_write( + handle, base.advanced(by: offset), bytes.count - offset + ) + guard written > 0 else { + throw self.error("Unable to upload \(remotePath)") + } + offset += written + meter.add(written) + } + } + } + meter.finish() + } + } + } + + public func downloadFile( + from remotePath: String, to localURL: URL, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await queue.perform { + try self.withRemoteReadHandle(at: remotePath) { handle in + let fm = FileManager.default + let temporary = localURL.deletingLastPathComponent().appendingPathComponent( + ".termind-part-\(UUID().uuidString)") + guard fm.createFile(atPath: temporary.path, contents: nil) else { + throw SFTPError.localFile("Unable to create \(temporary.path)") + } + var succeeded = false + defer { if !succeeded { try? fm.removeItem(at: temporary) } } + let file = try FileHandle(forWritingTo: temporary) + defer { try? file.close() } + var attrs = LIBSSH2_SFTP_ATTRIBUTES() + _ = libssh2_sftp_fstat_ex(handle, &attrs, 0) + let total = Int64(attrs.filesize) + var meter = SFTPProgressMeter(total: total, report: progress) + var buffer = [CChar](repeating: 0, count: 64 * 1024) + while true { + if cancellation.isCancelled { throw SFTPError.cancelled } + let count = libssh2_sftp_read(handle, &buffer, buffer.count) + if count > 0 { + try file.write(contentsOf: Data(bytes: buffer, count: count)) + meter.add(count) + } else if count == 0 { + break + } else { + throw self.error("Unable to download \(remotePath)") + } + } + try file.synchronize() + meter.finish() + if fm.fileExists(atPath: localURL.path) { + guard overwrite else { throw SFTPError.localFile("\(localURL.lastPathComponent) already exists.") } + _ = try fm.replaceItemAt(localURL, withItemAt: temporary) + } else { + try fm.moveItem(at: temporary, to: localURL) + } + succeeded = true + } + } + } + + public func downloadFile( + from remotePath: String, toSocket socket: Int32, + cancellation: TransferCancellationToken + ) async throws { + try await queue.perform { + try self.withRemoteReadHandle(at: remotePath) { handle in + var buffer = [CChar](repeating: 0, count: 64 * 1024) + while true { + if cancellation.isCancelled { throw SFTPError.cancelled } + let count = libssh2_sftp_read(handle, &buffer, buffer.count) + if count > 0 { + var offset = 0 + while offset < count { + if cancellation.isCancelled { throw SFTPError.cancelled } + let sent = buffer.withUnsafeBytes { bytes in + Darwin.send( + socket, bytes.baseAddress!.advanced(by: offset), + count - offset, 0 + ) + } + if sent > 0 { + offset += sent + } else if sent < 0, errno == EINTR { + continue + } else { + throw SFTPError.operation("The SFTP copy relay was interrupted.") + } + } + } else if count == 0 { + break + } else { + throw self.error("Unable to read \(remotePath)") + } + } + } + } + } + + public func uploadFile( + fromSocket socket: Int32, to remotePath: String, + byteCount: Int64, overwrite: Bool, + cancellation: TransferCancellationToken, + progress: @escaping @Sendable (SFTPProgress) -> Void + ) async throws { + try await queue.perform { + let sftp = try self.requireSFTP() + try self.withAtomicRemoteWrite( + sftp: sftp, destination: remotePath, overwrite: overwrite + ) { handle in + var meter = SFTPProgressMeter(total: byteCount, report: progress) + var buffer = [CChar](repeating: 0, count: 64 * 1024) + while true { + if cancellation.isCancelled { throw SFTPError.cancelled } + let count = Darwin.recv(socket, &buffer, buffer.count, 0) + if count > 0 { + try buffer.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) + else { return } + var offset = 0 + while offset < count { + if cancellation.isCancelled { throw SFTPError.cancelled } + let written = libssh2_sftp_write( + handle, base.advanced(by: offset), count - offset + ) + guard written > 0 else { + throw self.error("Unable to copy to \(remotePath)") + } + offset += written + meter.add(written) + } + } + } else if count == 0 { + break + } else if errno != EINTR { + throw SFTPError.operation("The SFTP copy relay was interrupted.") + } + } + guard meter.completed == byteCount else { + throw SFTPError.operation( + "The source changed while it was being copied " + + "(expected \(byteCount) bytes, received \(meter.completed))." + ) + } + meter.finish() + } + } + } + + private func withRemoteReadHandle( + at path: String, read: (OpaquePointer) throws -> T + ) throws -> T { + let sftp = try requireSFTP() + guard + let handle = path.withCString({ + libssh2_sftp_open_ex( + sftp, $0, UInt32(path.utf8.count), UInt(LIBSSH2_FXF_READ), 0, + Int32(LIBSSH2_SFTP_OPENFILE) + ) + }) + else { + throw error("Unable to open \(path)") + } + defer { _ = libssh2_sftp_close_handle(handle) } + return try read(handle) + } + + private func withAtomicRemoteWrite( + sftp: OpaquePointer, + destination: String, + overwrite: Bool, + temporaryTag: String = ".termind-part-", + write: (OpaquePointer) throws -> Void + ) throws { + let temporary = destination + temporaryTag + UUID().uuidString + let flags = UInt(LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC) + guard + let handle = temporary.withCString({ + libssh2_sftp_open_ex( + sftp, $0, UInt32(temporary.utf8.count), flags, 0o600, + Int32(LIBSSH2_SFTP_OPENFILE) + ) + }) + else { + throw error("Unable to create \(destination)") + } + var succeeded = false + defer { + _ = libssh2_sftp_close_handle(handle) + if !succeeded { + _ = temporary.withCString { + libssh2_sftp_unlink_ex(sftp, $0, UInt32(temporary.utf8.count)) + } + } + } + try write(handle) + try renameOnQueue( + sftp: sftp, from: temporary, to: destination, overwrite: overwrite + ) + succeeded = true + } + + private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws + { + let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 + let result = source.withCString { sourceBuffer in + destination.withCString { destinationBuffer in + libssh2_sftp_rename_ex( + sftp, sourceBuffer, UInt32(source.utf8.count), + destinationBuffer, UInt32(destination.utf8.count), flags + ) + } + } + guard result == 0 else { throw error("Unable to finish \(destination)") } + } + + private func requireSFTP() throws -> OpaquePointer { + guard let sftp else { throw SFTPError.notConnected } + return sftp + } + + private func error(_ prefix: String) -> SFTPError { + guard let sftp else { return .operation(prefix) } + let sftpCode = libssh2_sftp_last_error(sftp) + let sessionCode = session.map(libssh2_session_last_errno) ?? 0 + let message = "\(prefix): SFTP error \(sftpCode)" + let lostSFTPCodes = [ + UInt(LIBSSH2_FX_NO_CONNECTION), UInt(LIBSSH2_FX_CONNECTION_LOST), + ] + let lostSessionCodes = [ + LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, + LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED, + ] + if lostSFTPCodes.contains(sftpCode) || lostSessionCodes.contains(sessionCode) { + reportConnectionFailure(message) + return .connectionLost(message) + } + return .operation(message) + } + + private func cleanup() { + keepaliveTimer?.cancel() + keepaliveTimer = nil + if let sftp { + _ = libssh2_sftp_shutdown(sftp) + self.sftp = nil + } + if let session { + _ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed SFTP", "") + _ = libssh2_session_free(session) + self.session = nil + } + routeTransport?.close() + routeTransport = nil + connectionFailure = nil + didReportConnectionFailure = false + } + + private func startKeepalive(for session: OpaquePointer) { + libssh2_keepalive_config(session, 1, 15) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 15, repeating: 15) + timer.setEventHandler { [weak self] in + guard let self, self.session == session else { return } + var secondsToNext: Int32 = 0 + let result = libssh2_keepalive_send(session, &secondsToNext) + if result < 0, result != LIBSSH2_ERROR_EAGAIN { + let error = SSHSessionWorker.sessionError( + session, prefix: "SFTP keepalive failed" + ) + self.reportConnectionFailure(error.localizedDescription) + } + } + keepaliveTimer = timer + timer.resume() + } + + private func reportConnectionFailure(_ message: String) { + guard !didReportConnectionFailure else { return } + didReportConnectionFailure = true + connectionFailure?(message) + } + + private static func join(_ base: String, _ component: String) -> String { + base == "/" ? "/\(component)" : "\(base)/\(component)" + } + + private static func remoteFile( + path: String, name: String, attributes: LIBSSH2_SFTP_ATTRIBUTES + ) -> RemoteFile { + let mode = UInt32(attributes.permissions) + let kind: RemoteFile.Kind + if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFDIR) { + kind = .directory + } else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFREG) { + kind = .file + } else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFLNK) { + kind = .symbolicLink + } else if mode == 0 { + kind = .unknown + } else { + kind = .special + } + return RemoteFile( + path: path, name: name, kind: kind, size: Int64(attributes.filesize), + modifiedAt: attributes.mtime == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(attributes.mtime)), + permissions: mode, ownerUID: UInt32(attributes.uid), ownerGID: UInt32(attributes.gid) + ) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift new file mode 100644 index 00000000..058bfafe --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHExecSession.swift @@ -0,0 +1,399 @@ +import CSSH2 +import Darwin +import Foundation +import TermindApplication + +public nonisolated struct SSHExecResult: Hashable, Sendable { + public var stdout: String + public var stderr: String + public var exitCode: Int32 + public var signal: String? + public var stdoutTruncated: Bool + public var stderrTruncated: Bool + public init( + stdout: String, stderr: String, exitCode: Int32, signal: String? = nil, stdoutTruncated: Bool, + stderrTruncated: Bool + ) { + self.stdout = stdout + self.stderr = stderr + self.exitCode = exitCode + self.signal = signal + self.stdoutTruncated = stdoutTruncated + self.stderrTruncated = stderrTruncated + } + +} + +/// The one failure that is safe to retry on a fresh connection. +/// +/// It is thrown only while the remote command is provably *not running*: the +/// session was already gone, or the channel could not be opened or started. +/// Every failure after `process_startup` succeeds is rethrown unchanged, because +/// by then the command may have partially run and a transparent retry could +/// delete something twice. +public nonisolated enum SSHExecSessionError: LocalizedError, Sendable { + case channelUnavailable(any Error) + + public var errorDescription: String? { + switch self { + case .channelUnavailable(let underlying): underlying.localizedDescription + } + } +} + +/// A long-lived authenticated SSH session that runs each command on its own +/// non-PTY channel. +/// +/// Splitting connect from execute is the whole point: the handshake, host-key +/// check, and signature are paid once per host instead of once per command, and +/// a jump-host route stops rebuilding every hop for every `ls`. Semantics are +/// unchanged — a command still gets a private channel and a private +/// `/bin/sh -lc`, so there is no shared shell state and no cwd carry-over. +public nonisolated protocol SSHExecSessionProtocol: AnyObject, Sendable { + func connect(using configuration: SSHConnectionConfiguration) async throws + func execute( + _ command: String, workingDirectory: String?, stdin: Data?, + timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? + ) async throws -> SSHExecResult + func disconnect() async +} + +/// Called with each chunk the command produces, and which stream it came from — +/// `false` for stdout, `true` for stderr. +/// +/// It runs on the session's own serial queue, between two `libssh2` reads, so it +/// must return promptly and must never await: anything it blocks on stalls every +/// command on that host. Appending to a lock-guarded buffer is the intended +/// shape. +public typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void + +public actor SSHExecSession: SSHExecSessionProtocol { + public init() {} + private nonisolated let worker = SSHExecWorker() + + public func connect(using configuration: SSHConnectionConfiguration) async throws { + try await worker.connect(using: configuration) + } + + /// Actor-isolated, so commands on one session queue behind each other. That + /// is not incidental: a libssh2 session cannot be driven from two places at + /// once, and the worker's queue would serialize them anyway. + public func execute( + _ command: String, workingDirectory: String?, stdin: Data?, + timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? + ) async throws -> SSHExecResult { + let cancellation = TransferCancellationToken() + return try await withTaskCancellationHandler { + try await worker.execute( + command, workingDirectory: workingDirectory, stdin: stdin, + timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, + onOutput: onOutput + ) + } onCancel: { + cancellation.cancel() + } + } + + public func disconnect() async { await worker.disconnect() } +} + +private nonisolated final class SSHExecWorker: @unchecked Sendable { + /// Every libssh2 call for this session happens here, the idle keepalive + /// included. libssh2 sessions are not thread-safe, and serializing on one + /// queue is what makes reuse across commands safe at all. + private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.exec") + private var routeTransport: SSHRoutedSocket? + private var session: OpaquePointer? + private var keepaliveTimer: (any DispatchSourceTimer)? + /// Set when the transport is known to be gone. The next `execute` reports + /// `channelUnavailable` instead of calling into a session that cannot answer. + private var isDead = false + + deinit { cleanup() } + + public func connect(using configuration: SSHConnectionConfiguration) async throws { + try await queue.perform { + self.cleanup() + let (transport, session) = try SSHSessionWorker.openAuthenticatedSession( + using: configuration, timeout: 20 + ) + self.routeTransport = transport + self.session = session + self.isDead = false + self.startKeepalive(for: session) + } + } + + public func disconnect() async { await queue.perform { self.cleanup() } } + + public func execute( + _ command: String, workingDirectory: String?, stdin: Data?, + timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, + onOutput: SSHExecOutputHandler? + ) async throws -> SSHExecResult { + try await queue.perform { + do { + return try self.executeOnQueue( + command, workingDirectory: workingDirectory, stdin: stdin, + timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, + onOutput: onOutput + ) + } catch { + // Still on the queue, and the channel defer has already run, so + // this is the right moment to give the socket back rather than + // leave a corpse in the pool for the idle sweep to find. + if self.isDead { self.cleanup() } + throw error + } + } + } + + private func executeOnQueue( + _ command: String, workingDirectory: String?, stdin: Data?, + timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, + onOutput: SSHExecOutputHandler? + ) throws -> SSHExecResult { + guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw TermindSSHError.library("The command is empty.") + } + guard let session, !isDead else { + throw SSHExecSessionError.channelUnavailable(TermindSSHError.disconnected) + } + // Blocking for the two setup calls, non-blocking for the pump below. + libssh2_session_set_blocking(session, 1) + guard + let channel = libssh2_channel_open_ex( + session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0 + ) + else { + throw notStarted(session, prefix: "Unable to open SSH exec channel") + } + // Only a command that ran to completion leaves a channel worth closing + // politely, and a session worth keeping. + var completed = false + defer { + if completed { + libssh2_session_set_blocking(session, 1) + _ = libssh2_channel_free(channel) + } else { + // Timed out, cancelled, or the transport failed: the command may + // still be running and the channel is mid-stream. Blocking here + // to close it courteously would stall every later command on this + // host, so give the whole session up instead — `cleanup` frees + // it, and freeing a session reclaims its channels with it. + isDead = true + } + } + + let effective: String + if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), + !cwd.isEmpty + { + effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))" + } else { + effective = "/bin/sh -lc \(Self.shellQuote(command))" + } + let started = effective.withCString { + libssh2_channel_process_startup(channel, "exec", 4, $0, UInt32(effective.utf8.count)) + } + guard started == 0 else { + throw notStarted(session, prefix: "Unable to start remote command") + } + // Past this line the command is running. Nothing below may be retried. + libssh2_session_set_blocking(session, 0) + + var pendingInput = stdin ?? Data() + if !pendingInput.isEmpty, pendingInput.last != 0x0A { pendingInput.append(0x0A) } + var inputOffset = 0 + var sentEOF = pendingInput.isEmpty + if sentEOF { _ = libssh2_channel_send_eof(channel) } + var standardOut = Data() + var standardError = Data() + var outTruncated = false + var errorTruncated = false + var buffer = [CChar](repeating: 0, count: 16 * 1_024) + let deadline = Date().addingTimeInterval(max(1, min(timeout, 15 * 60))) + var lastKeepalive = Date() + + while libssh2_channel_eof(channel) == 0 { + if cancellation.isCancelled { throw CancellationError() } + if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") } + var progressed = false + if inputOffset < pendingInput.count { + let written = pendingInput.withUnsafeBytes { bytes in + libssh2_channel_write_ex( + channel, 0, + bytes.baseAddress!.advanced(by: inputOffset) + .assumingMemoryBound(to: CChar.self), + bytes.count - inputOffset + ) + } + if written > 0 { + inputOffset += written + progressed = true + } else if written != LIBSSH2_ERROR_EAGAIN { + throw failed(session, prefix: "Unable to write command input") + } + } else if !sentEOF { + let result = libssh2_channel_send_eof(channel) + if result == 0 { + sentEOF = true + progressed = true + } else if result != LIBSSH2_ERROR_EAGAIN { + throw failed(session, prefix: "Unable to finish command input") + } + } + for stream in [Int32(0), Int32(1)] { + while true { + let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count) + if count > 0 { + progressed = true + let chunk = Data(bytes: buffer, count: count) + onOutput?(chunk, stream != 0) + if stream == 0 { + Self.appendCapped( + chunk, to: &standardOut, limit: outputLimit, + truncated: &outTruncated + ) + } else { + Self.appendCapped( + chunk, to: &standardError, limit: outputLimit, + truncated: &errorTruncated + ) + } + } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { + break + } else { + throw failed(session, prefix: "Unable to read command output") + } + } + } + if !progressed { + // The idle timer cannot fire while this queue is busy, so a + // long-running quiet command has to keep its own session alive. + if Date().timeIntervalSince(lastKeepalive) >= 30 { + lastKeepalive = Date() + var next = Int32(0) + let sent = libssh2_keepalive_send(session, &next) + if sent < 0, sent != LIBSSH2_ERROR_EAGAIN { + throw failed(session, prefix: "SSH keepalive failed") + } + } + usleep(10_000) + } + } + // Drain bytes buffered alongside EOF. + for stream in [Int32(0), Int32(1)] { + while true { + let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count) + guard count > 0 else { break } + let chunk = Data(bytes: buffer, count: count) + onOutput?(chunk, stream != 0) + if stream == 0 { + Self.appendCapped( + chunk, to: &standardOut, limit: outputLimit, truncated: &outTruncated + ) + } else { + Self.appendCapped( + chunk, to: &standardError, limit: outputLimit, truncated: &errorTruncated + ) + } + } + } + while true { + let closed = libssh2_channel_close(channel) + if closed == 0 { break } + if closed != LIBSSH2_ERROR_EAGAIN { + throw failed(session, prefix: "Unable to close SSH exec channel") + } + if cancellation.isCancelled { throw CancellationError() } + if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") } + usleep(10_000) + } + // The remote is done and the channel is closed, so the session is clean + // and can carry the next command. + completed = true + var exitSignal: UnsafeMutablePointer? + var exitSignalLength = 0 + let signalResult = libssh2_channel_get_exit_signal( + channel, &exitSignal, &exitSignalLength, nil, nil, nil, nil + ) + let signal: String? + if signalResult == 0, let exitSignal { + signal = String( + decoding: UnsafeBufferPointer( + start: exitSignal, count: exitSignalLength + ).map(UInt8.init(bitPattern:)), as: UTF8.self) + libssh2_free(session, exitSignal) + } else { + signal = nil + } + return SSHExecResult( + stdout: String(decoding: standardOut, as: UTF8.self), + stderr: String(decoding: standardError, as: UTF8.self), + exitCode: Int32(libssh2_channel_get_exit_status(channel)), + signal: signal, + stdoutTruncated: outTruncated, stderrTruncated: errorTruncated + ) + } + + /// The command never started, so the caller may open a fresh connection and + /// try again exactly once. + private func notStarted(_ session: OpaquePointer, prefix: String) -> SSHExecSessionError { + SSHExecSessionError.channelUnavailable(failed(session, prefix: prefix)) + } + + /// Records whether the session is still usable, then describes the failure. + private func failed(_ session: OpaquePointer, prefix: String) -> TermindSSHError { + let transport: [Int32] = [ + LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, + LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT, + LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED, + ] + if transport.contains(libssh2_session_last_errno(session)) { isDead = true } + return SSHSessionWorker.sessionError(session, prefix: prefix) + } + + private func startKeepalive(for session: OpaquePointer) { + libssh2_keepalive_config(session, 1, 30) + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now() + 30, repeating: 30) + timer.setEventHandler { [weak self] in + guard let self, self.session == session else { return } + var secondsToNext: Int32 = 0 + let result = libssh2_keepalive_send(session, &secondsToNext) + if result < 0, result != LIBSSH2_ERROR_EAGAIN { + self.isDead = true + self.cleanup() + } + } + keepaliveTimer = timer + timer.resume() + } + + private func cleanup() { + keepaliveTimer?.cancel() + keepaliveTimer = nil + if let session { + _ = libssh2_session_disconnect_ex( + session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the exec session", "" + ) + _ = libssh2_session_free(session) + self.session = nil + } + routeTransport?.close() + routeTransport = nil + } + + private static func shellQuote(_ value: String) -> String { + "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" + } + + private static func appendCapped( + _ chunk: Data, to destination: inout Data, limit: Int, truncated: inout Bool + ) { + let remaining = max(0, limit - destination.count) + if chunk.count > remaining { truncated = true } + if remaining > 0 { destination.append(chunk.prefix(remaining)) } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift new file mode 100644 index 00000000..f6921a2b --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHForwardingSession.swift @@ -0,0 +1,521 @@ +import CSSH2 +import Darwin +import Foundation +import TermindApplication +import TermindDomain + +public actor SSHForwardingSession { + public typealias ConnectionHandler = @Sendable (PortForwardingRule.ID, Int) -> Void + public typealias FailureHandler = @Sendable (String) -> Void + + private let worker: SSHForwardingWorker + + public init( + connectionHandler: @escaping ConnectionHandler, + failureHandler: @escaping FailureHandler + ) { + worker = SSHForwardingWorker( + connectionHandler: connectionHandler, failureHandler: failureHandler + ) + } + + public func connect(_ configuration: SSHConnectionConfiguration) async throws { + try await worker.connect(configuration) + } + + public func start(_ rule: PortForwardingRule) async throws { + try await worker.start(rule) + } + + public func stop(_ id: PortForwardingRule.ID) async { + await worker.stop(id) + } + + public func disconnect() async { + await worker.disconnect() + } +} + +private nonisolated final class SSHForwardingWorker: @unchecked Sendable { + private static let initialization: Void = { + precondition(libssh2_init(0) == 0, "libssh2 initialization failed") + }() + + private final class RuleContext { + let rule: PortForwardingRule + var socket: Int32 = -1 + var remoteListener: OpaquePointer? + var flows: [UUID: Flow] = [:] + + init(rule: PortForwardingRule) { self.rule = rule } + } + + private final class Flow { + enum Phase { case greeting, request, forwarding } + let id = UUID() + var socket: Int32 + var channel: OpaquePointer? + var phase: Phase + var handshake = Data() + var toChannel = Data() + var toSocket = Data() + var socketEOF = false + var channelEOF = false + var socketWriteClosed = false + var channelWriteClosed = false + + init(socket: Int32, dynamic: Bool, channel: OpaquePointer? = nil) { + self.socket = socket + self.phase = dynamic ? .greeting : .forwarding + self.channel = channel + } + } + + private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.forwarding") + private let connectionHandler: SSHForwardingSession.ConnectionHandler + private let failureHandler: SSHForwardingSession.FailureHandler + private var socket: Int32 = -1 + private var routeTransport: SSHRoutedSocket? + private var session: OpaquePointer? + private var rules: [PortForwardingRule.ID: RuleContext] = [:] + private var timer: (any DispatchSourceTimer)? + + public init( + connectionHandler: @escaping SSHForwardingSession.ConnectionHandler, + failureHandler: @escaping SSHForwardingSession.FailureHandler + ) { + _ = Self.initialization + self.connectionHandler = connectionHandler + self.failureHandler = failureHandler + } + + public func connect(_ configuration: SSHConnectionConfiguration) async throws { + try await withCheckedThrowingContinuation { continuation in + queue.async { [self] in + do { + cleanup() + routeTransport = try SSHRoutedSocket.open( + host: configuration.host, port: configuration.port, + proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20 + ) + guard let routeTransport else { throw TermindSSHError.disconnected } + socket = routeTransport.socket + guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw TermindSSHError.library(String(localized: "Unable to create an SSH forwarding session.")) + } + session = created + libssh2_session_set_blocking(created, 1) + guard libssh2_session_handshake(created, socket) == 0 else { + throw SSHSessionWorker.sessionError(created, prefix: String(localized: "SSH handshake failed")) + } + let observed = try SSHSessionWorker.observation( + host: configuration.host, port: configuration.port, session: created + ) + try SSHSessionWorker.validateHostKey( + observed, expected: configuration.expectedHostKeys + ) + try SSHSessionWorker.authenticate( + username: configuration.username, + credentials: configuration.credentials, session: created + ) + libssh2_session_set_blocking(created, 0) + libssh2_keepalive_config(created, 1, 20) + startTimer() + continuation.resume() + } catch { + cleanup() + continuation.resume(throwing: error) + } + } + } + } + + public func start(_ rule: PortForwardingRule) async throws { + try await withCheckedThrowingContinuation { continuation in + queue.async { [self] in + do { + guard let session else { throw TermindSSHError.disconnected } + if rules[rule.id] != nil { + continuation.resume() + return + } + let context = RuleContext(rule: rule) + switch rule.kind { + case .local, .dynamic: + do { + context.socket = try makeListener( + host: rule.bindHost, port: rule.bindPort + ) + } catch { + throw TermindSSHError.route( + String( + localized: "Unable to listen on \(rule.bindAddress): \(error.localizedDescription)") + ) + } + case .remote: + var boundPort: Int32 = 0 + let listener = rule.bindHost.withCString { host in + libssh2_channel_forward_listen_ex( + session, host, Int32(rule.bindPort), &boundPort, 64 + ) + } + guard let listener else { + throw TermindSSHError.route( + SSHSessionWorker.sessionError( + session, prefix: String(localized: "Unable to start remote forwarding") + ).localizedDescription + ) + } + context.remoteListener = listener + } + rules[rule.id] = context + connectionHandler(rule.id, 0) + continuation.resume() + } catch { continuation.resume(throwing: error) } + } + } + } + + public func stop(_ id: PortForwardingRule.ID) async { + await withCheckedContinuation { continuation in + queue.async { [self] in + if let context = rules.removeValue(forKey: id) { close(context) } + continuation.resume() + } + } + } + + public func disconnect() async { + await withCheckedContinuation { continuation in + queue.async { [self] in + cleanup() + continuation.resume() + } + } + } + + private func startTimer() { + timer?.cancel() + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: .milliseconds(12), leeway: .milliseconds(4)) + source.setEventHandler { [weak self] in self?.pump() } + timer = source + source.resume() + } + + private func pump() { + guard let session else { return } + for context in Array(rules.values) { + switch context.rule.kind { + case .local, .dynamic: acceptLocal(context, session: session) + case .remote: acceptRemote(context) + } + for flow in Array(context.flows.values) { pump(flow, in: context, session: session) } + connectionHandler(context.rule.id, context.flows.count) + } + var next = Int32(0) + let keepalive = libssh2_keepalive_send(session, &next) + if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { + let message = SSHSessionWorker.sessionError( + session, prefix: String(localized: "The forwarding SSH connection closed") + ).localizedDescription + cleanup() + failureHandler(message) + } + } + + private func acceptLocal(_ context: RuleContext, session: OpaquePointer) { + while true { + let accepted = Darwin.accept(context.socket, nil, nil) + guard accepted >= 0 else { break } + makeNonblocking(accepted) + let flow = Flow(socket: accepted, dynamic: context.rule.kind == .dynamic) + if context.rule.kind == .local { + guard let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort else { + Darwin.close(accepted) + continue + } + flow.channel = targetHost.withCString { target in + libssh2_channel_direct_tcpip_ex(session, target, Int32(targetPort), "127.0.0.1", 0) + } + guard flow.channel != nil else { + Darwin.close(accepted) + continue + } + } + context.flows[flow.id] = flow + } + } + + private func acceptRemote(_ context: RuleContext) { + guard let listener = context.remoteListener, + let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort + else { return } + while let channel = libssh2_channel_forward_accept(listener) { + do { + let local = try SSHSocketConnector.open( + host: targetHost, port: targetPort, timeout: 5 + ) + makeNonblocking(local) + let flow = Flow(socket: local, dynamic: false, channel: channel) + context.flows[flow.id] = flow + } catch { + _ = libssh2_channel_free(channel) + } + } + } + + private func pump(_ flow: Flow, in context: RuleContext, session: OpaquePointer) { + let highWaterMark = 2 * 1024 * 1024 + if !flow.socketEOF, + (flow.phase == .forwarding ? flow.toChannel.count : flow.handshake.count) < highWaterMark + { + var buffer = [UInt8](repeating: 0, count: 32 * 1024) + let count = Darwin.recv(flow.socket, &buffer, buffer.count, 0) + if count > 0 { + let data = Data(buffer.prefix(count)) + if flow.phase == .forwarding { + flow.toChannel.append(data) + } else { + flow.handshake.append(data) + if flow.handshake.count > 64 * 1024 { + close(flow, in: context) + return + } + handleSOCKSHandshake(flow, session: session) + } + } else if count == 0 { + flow.socketEOF = true + } else if errno != EAGAIN && errno != EWOULDBLOCK { + close(flow, in: context) + return + } + } + + if let channel = flow.channel { + while !flow.toChannel.isEmpty { + let written = flow.toChannel.withUnsafeBytes { bytes -> Int in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } + return libssh2_channel_write_ex(channel, 0, base, bytes.count) + } + if written > 0 { + flow.toChannel.removeFirst(written) + } else if written == LIBSSH2_ERROR_EAGAIN { + break + } else { + close(flow, in: context) + return + } + } + if flow.socketEOF, flow.toChannel.isEmpty, !flow.channelWriteClosed { + let result = libssh2_channel_send_eof(channel) + if result == 0 { + flow.channelWriteClosed = true + } else if result != LIBSSH2_ERROR_EAGAIN { + close(flow, in: context) + return + } + } + if flow.toSocket.count < highWaterMark { + var buffer = [CChar](repeating: 0, count: 32 * 1024) + while flow.toSocket.count < highWaterMark { + let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count) + if count > 0 { + flow.toSocket.append(Data(bytes: buffer, count: count)) + } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { + break + } else { + close(flow, in: context) + return + } + } + } + if libssh2_channel_eof(channel) == 1 { flow.channelEOF = true } + } + + while !flow.toSocket.isEmpty { + let sent = flow.toSocket.withUnsafeBytes { bytes -> Int in + guard let base = bytes.baseAddress else { return 0 } + return Darwin.send(flow.socket, base, bytes.count, 0) + } + if sent > 0 { + flow.toSocket.removeFirst(sent) + } else if errno == EAGAIN || errno == EWOULDBLOCK { + break + } else { + close(flow, in: context) + return + } + } + if flow.channelEOF, flow.toSocket.isEmpty, !flow.socketWriteClosed { + _ = Darwin.shutdown(flow.socket, SHUT_WR) + flow.socketWriteClosed = true + } + if flow.socketEOF, + flow.channel == nil || flow.channelEOF, + flow.toChannel.isEmpty, flow.toSocket.isEmpty + { + close(flow, in: context) + } + } + + private func handleSOCKSHandshake(_ flow: Flow, session: OpaquePointer) { + switch flow.phase { + case .greeting: + guard flow.handshake.count >= 2 else { return } + let methods = Int(flow.handshake[1]) + guard flow.handshake.count >= 2 + methods else { return } + guard flow.handshake[0] == 0x05, + flow.handshake[2..<(2 + methods)].contains(0x00) + else { + flow.toSocket.append(contentsOf: [0x05, 0xff]) + flow.socketEOF = true + return + } + flow.handshake.removeFirst(2 + methods) + flow.toSocket.append(contentsOf: [0x05, 0x00]) + flow.phase = .request + handleSOCKSHandshake(flow, session: session) + case .request: + guard flow.handshake.count >= 4 else { return } + guard flow.handshake[0] == 0x05, flow.handshake[1] == 0x01 else { + flow.toSocket.append(socksReply(0x07)) + flow.socketEOF = true + return + } + let parsed: (String, Int, Int)? + switch flow.handshake[3] { + case 0x01: + guard flow.handshake.count >= 10 else { return } + parsed = ( + flow.handshake[4..<8].map(String.init).joined(separator: "."), + Int(flow.handshake[8]) << 8 | Int(flow.handshake[9]), 10 + ) + case 0x03: + guard flow.handshake.count >= 5 else { return } + let length = Int(flow.handshake[4]) + guard flow.handshake.count >= 7 + length else { return } + parsed = ( + String(decoding: flow.handshake[5..<(5 + length)], as: UTF8.self), + Int(flow.handshake[5 + length]) << 8 | Int(flow.handshake[6 + length]), + 7 + length + ) + case 0x04: + guard flow.handshake.count >= 22 else { return } + var address = in6_addr() + _ = withUnsafeMutableBytes(of: &address) { destination in + flow.handshake[4..<20].copyBytes(to: destination) + } + var chars = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) + _ = inet_ntop(AF_INET6, &address, &chars, socklen_t(chars.count)) + let end = chars.firstIndex(of: 0) ?? chars.endIndex + parsed = ( + String(decoding: chars[.. Data { + Data([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) + } + + private func makeListener(host: String, port: Int) throws -> Int32 { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + hints.ai_flags = AI_PASSIVE + var result: UnsafeMutablePointer? + let resolved = getaddrinfo(host, String(port), &hints, &result) + guard resolved == 0, let first = result else { + throw TermindSSHError.socket(String(cString: gai_strerror(resolved))) + } + defer { freeaddrinfo(first) } + var cursor: UnsafeMutablePointer? = first + while let info = cursor?.pointee { + let candidate = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol) + if candidate >= 0 { + var yes: Int32 = 1 + setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) + if Darwin.bind(candidate, info.ai_addr, info.ai_addrlen) == 0, + Darwin.listen(candidate, 128) == 0 + { + makeNonblocking(candidate) + return candidate + } + Darwin.close(candidate) + } + cursor = info.ai_next + } + throw TermindSSHError.socket(String(cString: strerror(errno))) + } + + private func makeNonblocking(_ socket: Int32) { + let flags = fcntl(socket, F_GETFL, 0) + if flags >= 0 { _ = fcntl(socket, F_SETFL, flags | O_NONBLOCK) } + } + + private func close(_ flow: Flow, in context: RuleContext) { + context.flows[flow.id] = nil + if flow.socket >= 0 { + Darwin.close(flow.socket) + flow.socket = -1 + } + if let channel = flow.channel { + _ = libssh2_channel_close(channel) + _ = libssh2_channel_free(channel) + flow.channel = nil + } + } + + private func close(_ context: RuleContext) { + for flow in Array(context.flows.values) { close(flow, in: context) } + if context.socket >= 0 { + Darwin.close(context.socket) + context.socket = -1 + } + if let listener = context.remoteListener { + _ = libssh2_channel_forward_cancel(listener) + context.remoteListener = nil + } + } + + private func cleanup() { + timer?.cancel() + timer = nil + for context in Array(rules.values) { close(context) } + rules.removeAll() + if let session { + _ = libssh2_session_disconnect_ex( + session, SSH_DISCONNECT_BY_APPLICATION, "Termind stopped forwarding", "" + ) + _ = libssh2_session_free(session) + self.session = nil + } + socket = -1 + routeTransport?.close() + routeTransport = nil + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift new file mode 100644 index 00000000..cf608c52 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHKeyMaterial.swift @@ -0,0 +1,331 @@ +import CSSH2 +import CryptoKit +import Foundation +import OpenSSL +import TermindApplication +import TermindDomain + +public nonisolated enum SSHKeyMaterialError: LocalizedError { + case invalidPrivateKey(String) + case unsupportedAlgorithm(String) + case generationFailed(String) + + public var errorDescription: String? { + switch self { + case .invalidPrivateKey(let detail): + String(localized: "The private key could not be read: \(detail)") + case .unsupportedAlgorithm(let name): + String(localized: "Unsupported SSH key algorithm: \(name)") + case .generationFailed(let detail): + String(localized: "Key generation failed: \(detail)") + } + } +} + +public nonisolated struct SSHKeyMaterial: Sendable { + public nonisolated enum Cipher: String, CaseIterable, Identifiable, Hashable, Sendable { + case aes256 + case aes128 + case tripleDES + + public var id: String { rawValue } + + public var displayName: String { + switch self { + case .aes256: "AES-256" + case .aes128: "AES-128" + case .tripleDES: "3DES" + } + } + + fileprivate var openSSLName: String { + switch self { + case .aes256: "AES-256-CBC" + case .aes128: "AES-128-CBC" + case .tripleDES: "DES-EDE3-CBC" + } + } + } + + public nonisolated enum Generation: Hashable, Sendable { + case ed25519 + case ecdsa(bits: Int) + case rsa(bits: Int) + } + + public var privateKey: String + public var publicKey: String + public var algorithm: SSHKeyAlgorithm + public var fingerprint: String + public var keySize: Int? + + public static func generate( + _ generation: Generation, comment: String, passphrase: String?, + cipher: Cipher = .aes256, rounds: Int = 100 + ) throws -> SSHKeyMaterial { + let key = try generateOpenSSLKey(generation) + defer { EVP_PKEY_free(key) } + let passphrase = normalizedPassphrase(passphrase) + let privateKey = try encodePrivateKey( + key, passphrase: passphrase, cipher: cipher, rounds: rounds + ) + return try inspect( + privateKey: privateKey, passphrase: passphrase, + comment: comment, keySize: generation.keySize + ) + } + + public static func inspect( + privateKey: String, passphrase: String?, comment: String = "", + keySize: Int? = nil + ) throws -> SSHKeyMaterial { + _ = initialization + guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw SSHKeyMaterialError.invalidPrivateKey("Unable to create a libssh2 parser session.") + } + defer { _ = libssh2_session_free(session) } + + var method: UnsafeMutablePointer? + var methodLength = 0 + var blob: UnsafeMutablePointer? + var blobLength = 0 + let result = privateKey.withCString { privateBuffer in + let parse: (UnsafePointer?) -> Int32 = { passphraseBuffer in + libssh2PublicKeyFromPrivateMemory( + session, &method, &methodLength, &blob, &blobLength, + privateBuffer, privateKey.utf8.count, passphraseBuffer + ) + } + return normalizedPassphrase(passphrase)?.withCString(parse) ?? parse(nil) + } + defer { + if let method { libssh2_free(session, method) } + if let blob { libssh2_free(session, blob) } + } + guard result == 0, let method, let blob, methodLength > 0, blobLength > 0 else { + throw SSHKeyMaterialError.invalidPrivateKey(sessionError(session, code: result)) + } + + let methodBytes = UnsafeBufferPointer(start: method, count: methodLength) + let name = String(decoding: methodBytes, as: UTF8.self) + let publicBlob = Data(bytes: blob, count: blobLength) + let algorithm = try algorithm(named: name) + let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines) + let publicKey = + "\(name) \(publicBlob.base64EncodedString())" + + (trimmedComment.isEmpty ? "" : " \(trimmedComment)") + return SSHKeyMaterial( + privateKey: privateKey, publicKey: publicKey, algorithm: algorithm, + fingerprint: fingerprint(of: publicBlob), + keySize: keySize ?? inferredKeySize(for: algorithm, publicBlob: publicBlob) + ) + } + + private static let initialization: Void = { + precondition(libssh2_init(0) == 0, "libssh2 initialization failed") + }() + + private static func generateOpenSSLKey(_ generation: Generation) throws -> OpaquePointer { + let name: String + switch generation { + case .ed25519: name = "ED25519" + case .ecdsa: name = "EC" + case .rsa: name = "RSA" + } + guard let context = EVP_PKEY_CTX_new_from_name(nil, name, nil) else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + defer { EVP_PKEY_CTX_free(context) } + guard EVP_PKEY_keygen_init(context) > 0 else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + switch generation { + case .ed25519: + break + case .ecdsa(let bits): + let curve: String + switch bits { + case 256: curve = "prime256v1" + case 384: curve = "secp384r1" + case 521: curve = "secp521r1" + default: throw SSHKeyMaterialError.unsupportedAlgorithm("ECDSA P-\(bits)") + } + guard curve.withCString({ EVP_PKEY_CTX_set_group_name(context, $0) }) > 0 else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + case .rsa(let bits): + guard [2048, 3072, 4096].contains(bits) else { + throw SSHKeyMaterialError.unsupportedAlgorithm("RSA \(bits)") + } + guard EVP_PKEY_CTX_set_rsa_keygen_bits(context, Int32(bits)) > 0 else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + } + var key: OpaquePointer? + guard EVP_PKEY_keygen(context, &key) > 0, let key else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + return key + } + + private static func encodePrivateKey( + _ key: OpaquePointer, passphrase: String?, cipher: Cipher, rounds: Int + ) throws -> String { + _ = initialization + guard let output = BIO_new(BIO_s_mem()) else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + defer { BIO_free(output) } + let result: Int32 + if let passphrase { + guard rounds > 0 else { + throw SSHKeyMaterialError.generationFailed( + String(localized: "Rounds must be greater than zero.") + ) + } + guard let keyInfo = EVP_PKEY2PKCS8(key) else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + defer { PKCS8_PRIV_KEY_INFO_free(keyInfo) } + guard + let cipherValue = cipher.openSSLName.withCString({ + EVP_CIPHER_fetch(nil, $0, nil) + }) + else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + defer { EVP_CIPHER_free(cipherValue) } + let encrypted = passphrase.withCString { buffer in + PKCS8_encrypt_ex( + -1, cipherValue, buffer, Int32(passphrase.utf8.count), + nil, 0, Int32(rounds), keyInfo, nil, nil + ) + } + guard let encrypted else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + defer { X509_SIG_free(encrypted) } + result = PEM_write_bio_PKCS8(output, encrypted) + } else { + result = PEM_write_bio_PrivateKey(output, key, nil, nil, 0, nil, nil) + } + guard result == 1 else { + throw SSHKeyMaterialError.generationFailed(openSSLError()) + } + let count = BIO_ctrl_pending(output) + var data = Data(count: count) + let read = data.withUnsafeMutableBytes { bytes in + BIO_read(output, bytes.baseAddress, Int32(count)) + } + guard read == count, let text = String(data: data, encoding: .utf8) else { + throw SSHKeyMaterialError.generationFailed("Unable to encode the generated private key.") + } + return text + } + + private static func algorithm(named name: String) throws -> SSHKeyAlgorithm { + switch name { + case "ssh-ed25519": .ed25519 + case "ecdsa-sha2-nistp256": .ecdsa256 + case "ecdsa-sha2-nistp384": .ecdsa384 + case "ecdsa-sha2-nistp521": .ecdsa521 + case "ssh-rsa": .rsa + default: throw SSHKeyMaterialError.unsupportedAlgorithm(name) + } + } + + private static func inferredKeySize( + for algorithm: SSHKeyAlgorithm, publicBlob: Data + ) -> Int? { + switch algorithm { + case .ed25519: 256 + case .ecdsa256: 256 + case .ecdsa384: 384 + case .ecdsa521: 521 + case .rsa: rsaKeySize(publicBlob) + } + } + + private static func rsaKeySize(_ blob: Data) -> Int? { + var offset = 0 + guard readSSHField(blob, offset: &offset) != nil, + readSSHField(blob, offset: &offset) != nil, + let modulus = readSSHField(blob, offset: &offset) + else { return nil } + let bytes = modulus.drop(while: { $0 == 0 }) + guard let first = bytes.first else { return nil } + return (bytes.count - 1) * 8 + (8 - first.leadingZeroBitCount) + } + + private static func readSSHField(_ data: Data, offset: inout Int) -> Data? { + guard offset + 4 <= data.count else { return nil } + let length = data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | Int($1) } + offset += 4 + guard length >= 0, offset + length <= data.count else { return nil } + defer { offset += length } + return data[offset..<(offset + length)] + } + + private static func normalizedPassphrase(_ value: String?) -> String? { + value?.isEmpty == false ? value : nil + } + + private static func fingerprint(of blob: Data) -> String { + let digest = Data(SHA256.hash(data: blob)).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + return "SHA256:\(digest)" + } + + private static func sessionError(_ session: OpaquePointer, code: Int32) -> String { + var message: UnsafeMutablePointer? + var length = Int32(0) + _ = libssh2_session_last_error(session, &message, &length, 0) + guard let message else { return "libssh2 error \(code)" } + let bytes = UnsafeBufferPointer(start: message, count: Int(length)) + .map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + + private static func openSSLError() -> String { + let code = ERR_get_error() + guard code != 0 else { return "Unknown OpenSSL error." } + var buffer = [CChar](repeating: 0, count: 256) + ERR_error_string_n(code, &buffer, buffer.count) + let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + public init( + privateKey: String, publicKey: String, algorithm: SSHKeyAlgorithm, fingerprint: String, keySize: Int? = nil + ) { + self.privateKey = privateKey + self.publicKey = publicKey + self.algorithm = algorithm + self.fingerprint = fingerprint + self.keySize = keySize + } + +} + +extension SSHKeyMaterial.Generation { + fileprivate nonisolated var keySize: Int? { + switch self { + case .ed25519: 256 + case .ecdsa(let bits), .rsa(let bits): bits + } + } +} + +/// libssh2's authentication API invokes this same parser when the public-key +/// argument is nil. Using it here keeps imports and authentication consistent, +/// including passphrase-protected OpenSSH keys. +@_silgen_name("_libssh2_pub_priv_keyfilememory") +private nonisolated func libssh2PublicKeyFromPrivateMemory( + _ session: OpaquePointer?, + _ method: UnsafeMutablePointer?>?, + _ methodLength: UnsafeMutablePointer?, + _ publicKey: UnsafeMutablePointer?>?, + _ publicKeyLength: UnsafeMutablePointer?, + _ privateKey: UnsafePointer?, + _ privateKeyLength: Int, + _ passphrase: UnsafePointer? +) -> Int32 diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift new file mode 100644 index 00000000..f7b17624 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHMetricsSampler.swift @@ -0,0 +1,125 @@ +import Foundation +import TermindApplication +import TermindDomain +import TermindInfrastructure + +import struct TermindDomain.Host + +public actor SSHMetricsSampler: MetricsSampling { + private let session: any SSHExecSessionProtocol + + public init(session: any SSHExecSessionProtocol) { self.session = session } + public func connect(_ configuration: SSHConnectionConfiguration) async throws { + try await session.connect(using: configuration) + } + public func disconnect() async { await session.disconnect() } + + public func sample() async throws -> MetricsSample { + let result = try await session.execute( + Self.command, workingDirectory: nil, stdin: nil, timeout: 8, + outputLimit: 262_144, onOutput: nil + ) + guard result.exitCode != 42 else { throw MetricsReadError.unsupported } + guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } + let sample = LinuxMetricsParser.parse(result.stdout) + guard sample.uptime != nil else { throw MetricsReadError.output } + return sample + } + + public func filesystems() async throws -> [MetricsFilesystem]? { + let result = try await session.execute( + "LC_ALL=C; export LC_ALL; cat /proc/self/mountinfo; printf '__TERMIND_DF__\\n'; df -PTkl 2>/dev/null || df -PTk", + workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 524_288, onOutput: nil + ) + guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } + return try LinuxFilesystemParser.parse(result.stdout) + } + + public func processes() async throws -> MetricsProcessSample? { + let result = try await session.execute( + Self.processCommand, workingDirectory: nil, stdin: nil, timeout: 5, + outputLimit: 1_048_576, onOutput: nil + ) + guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } + return try LinuxProcessParser.parse(result.stdout) + } + + private static let processCommand = """ + export LC_ALL=C + cat /proc/stat || exit 1 + printf 'pagesize '; getconf PAGESIZE 2>/dev/null || printf '\n' + printf '__TERMIND_PROCESSES__\n' + for f in /proc/[0-9]*/stat; do + if IFS= read -r line < "$f" 2>/dev/null; then printf '%s\n' "$line"; fi + done + exit 0 + """ + + public func gpu() async throws -> NvidiaSnapshot? { + let result = try await session.execute( + "command -v nvidia-smi >/dev/null 2>&1 || exit 42; LC_ALL=C nvidia-smi -q -x", + workingDirectory: nil, stdin: nil, timeout: 5, + outputLimit: NvidiaMetricsParser.maximumBytes, onOutput: nil + ) + // NVIDIA exit code 6 means no device was found; other errors remain errors. + if result.exitCode == 42 || result.exitCode == 6 { return nil } + guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } + return try NvidiaMetricsParser.parse(Data(result.stdout.utf8)) + } + + private static let command = """ + export LC_ALL=C + [ -r /proc/uptime ] && [ -r /proc/stat ] || exit 42 + printf '__TERMIND_UPTIME__\\n'; cat /proc/uptime + printf '__TERMIND_CPU__\\n'; cat /proc/stat + printf '__TERMIND_LOAD__\\n'; cat /proc/loadavg 2>/dev/null + printf '__TERMIND_MEMORY__\\n'; cat /proc/meminfo 2>/dev/null + printf '__TERMIND_DISKS__\\n' + while read -r major minor name stats; do + case "$name" in loop*|ram*|zram*) continue;; esac + [ -d "/sys/block/$name" ] || continue + set -- /sys/block/"$name"/slaves/* + [ -e "$1" ] && continue + printf '%s %s\\n' "$name" "$stats" + done < /proc/diskstats + printf '__TERMIND_BLOCKS__\\n' + while read -r major minor name stats; do + printf '%s:%s %s %s\\n' "$major" "$minor" "$name" "$stats" + done < /proc/diskstats + printf '__TERMIND_NETWORK__\\n'; cat /proc/net/dev 2>/dev/null + printf '__TERMIND_INTERFACES__\\n' + for i in /sys/class/net/*; do + [ -e "$i" ] || continue + kind=virtual; [ -e "$i/device" ] && kind=hardware + printf '%s %s\\n' "${i##*/}" "$kind" + done + printf '__TERMIND_ADDRESSES__\\n'; ip -o addr show 2>/dev/null + printf '__TERMIND_TCP__\\n'; cat /proc/net/snmp 2>/dev/null + exit 0 + """ +} + +public final class MetricsPreferences: MetricsConfigurationStoring { + private let defaults: UserDefaults + public init(defaults: UserDefaults = .standard) { self.defaults = defaults } + public func load() -> [MetricsSelection] { + guard let data = defaults.data(forKey: "metrics.servers") else { return [] } + return (try? JSONDecoder().decode([MetricsSelection].self, from: data)) ?? [] + } + public func save(_ selections: [MetricsSelection]) { + guard let data = try? JSONEncoder().encode(selections) else { return } + defaults.set(data, forKey: "metrics.servers") + } +} + +nonisolated extension RemoteConnectionCoordinator { + public func makeMetricsSampler() -> any MetricsSampling { SSHMetricsSampler(session: makeExecSession()) } + + public func discoverMetricsHostKey( + host: Host, proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration] + ) async throws -> SSHHostKeyObservation { + try await SSHSessionActor.discoverHostKey( + hostID: host.id, host: host.host, port: host.port, proxy: proxy, jumps: jumps + ) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift new file mode 100644 index 00000000..3e02590c --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -0,0 +1,330 @@ +import CSSH2 +import Darwin +import Foundation +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +/// One authenticated SSH hop used before the destination session. + +/// A socket carrying bytes to the final SSH server, either directly or through +/// a chain of authenticated direct-tcpip channels. The caller owns the final +/// libssh2 session; this object owns every preceding hop and its bridge pump. +public nonisolated final class SSHRoutedSocket: @unchecked Sendable { + public let socket: Int32 + private var bridges: [SSHChannelSocketBridge] + private var isClosed = false + + private init(socket: Int32, bridges: [SSHChannelSocketBridge]) { + self.socket = socket + self.bridges = bridges + } + + deinit { close() } + + public static func open( + host: String, port: Int, proxy: SSHTransportProxy?, + jumps: [SSHRouteHopConfiguration], timeout: TimeInterval, + progress: SSHRouteProgressHandler? = nil + ) throws -> SSHRoutedSocket { + guard !jumps.isEmpty else { + return SSHRoutedSocket( + socket: try SSHSocketConnector.open( + host: host, port: port, proxy: proxy, timeout: timeout + ), + bridges: [] + ) + } + + var bridges: [SSHChannelSocketBridge] = [] + var currentSocket = try SSHSocketConnector.open( + host: jumps[0].host, port: jumps[0].port, + proxy: jumps[0].proxy, timeout: timeout + ) + do { + for index in jumps.indices { + let hop = jumps[index] + progress?(.connecting(hop.id)) + let session = try connectHop( + hop, socket: currentSocket, level: index + 1, progress: progress + ) + progress?(.connected(hop.id)) + let nextHost: String + let nextPort: Int + let nextProxy: SSHTransportProxy? + if jumps.indices.contains(index + 1) { + let next = jumps[index + 1] + nextHost = next.host + nextPort = next.port + nextProxy = next.proxy + } else { + nextHost = host + nextPort = port + nextProxy = proxy + } + + let channelHost = nextProxy?.hostname ?? nextHost + let channelPort = nextProxy?.port ?? nextPort + guard + let channel = channelHost.withCString({ target in + libssh2_channel_direct_tcpip_ex( + session, target, Int32(channelPort), "127.0.0.1", 0 + ) + }) + else { + _ = libssh2_session_disconnect_ex( + session, SSH_DISCONNECT_BY_APPLICATION, "Termind route failed", "" + ) + _ = libssh2_session_free(session) + Darwin.close(currentSocket) + throw TermindSSHError.route( + "Jump Host \(index + 1) (\(hop.name)) denied TCP forwarding." + ) + } + + let bridge = try SSHChannelSocketBridge( + transportSocket: currentSocket, session: session, channel: channel, + timeout: timeout + ) + bridges.append(bridge) + currentSocket = bridge.clientSocket + if let nextProxy { + do { + try SSHSocketConnector.negotiate( + socket: currentSocket, host: nextHost, port: nextPort, + proxy: nextProxy + ) + } catch { + throw TermindSSHError.route( + "Proxy after Jump Host \(index + 1) (\(hop.name)): \(error.localizedDescription)" + ) + } + } + } + return SSHRoutedSocket(socket: currentSocket, bridges: bridges) + } catch { + Darwin.close(currentSocket) + for bridge in bridges.reversed() { bridge.close() } + throw error + } + } + + public func close() { + guard !isClosed else { return } + isClosed = true + Darwin.close(socket) + for bridge in bridges.reversed() { bridge.close() } + bridges.removeAll() + } + + private static func connectHop( + _ hop: SSHRouteHopConfiguration, socket: Int32, level: Int, + progress: SSHRouteProgressHandler? + ) throws -> OpaquePointer { + guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw TermindSSHError.route("Jump Host \(level) (\(hop.name)): unable to create SSH session.") + } + do { + libssh2_session_set_blocking(session, 1) + guard libssh2_session_handshake(session, socket) == 0 else { + throw SSHSessionWorker.sessionError(session, prefix: "SSH handshake failed") + } + let observed = try SSHSessionWorker.observation( + host: hop.host, port: hop.port, session: session + ) + try SSHSessionWorker.validateHostKey(observed, expected: hop.expectedHostKeys) + progress?(.authenticating(hop.id)) + try SSHSessionWorker.authenticate( + username: hop.username, credentials: hop.credentials, session: session + ) + progress?(.authenticated(hop.id)) + return session + } catch { + _ = libssh2_session_disconnect_ex( + session, SSH_DISCONNECT_BY_APPLICATION, "Termind route failed", "" + ) + _ = libssh2_session_free(session) + throw TermindSSHError.routeHost( + hostID: hop.id, + authentication: (error as? TermindSSHError)?.shouldRequestAuthentication == true, + serverChallenge: (error as? TermindSSHError)?.isServerAuthenticationChallenge == true, + hostKey: (error as? TermindSSHError)?.isHostKeyChanged == true, + message: "Jump Host \(level) (\(hop.name)): \(error.localizedDescription)" + ) + } + } +} + +/// Bridges a direct-tcpip channel into a normal file descriptor for the next +/// libssh2 session. All calls touching the hop's libssh2 session are confined +/// to this queue. +private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable { + public let clientSocket: Int32 + + private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-bridge") + private var transportSocket: Int32 + private var pumpSocket: Int32 + private var session: OpaquePointer? + private var channel: OpaquePointer? + private var timer: (any DispatchSourceTimer)? + private var toChannel = Data() + private var toSocket = Data() + private var socketEOF = false + private var channelEOF = false + private var channelWriteClosed = false + private var isClosed = false + + public init( + transportSocket: Int32, session: OpaquePointer, channel: OpaquePointer, + timeout: TimeInterval + ) throws { + var pair: [Int32] = [-1, -1] + guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else { + _ = libssh2_channel_free(channel) + _ = libssh2_session_free(session) + Darwin.close(transportSocket) + throw TermindSSHError.socket(String(cString: strerror(errno))) + } + self.transportSocket = transportSocket + self.session = session + self.channel = channel + pumpSocket = pair[0] + clientSocket = pair[1] + + var yes: Int32 = 1 + for descriptor in pair { + setsockopt( + descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes, + socklen_t(MemoryLayout.size) + ) + } + var interval = timeval(tv_sec: Int(timeout), tv_usec: 0) + setsockopt( + clientSocket, SOL_SOCKET, SO_RCVTIMEO, &interval, + socklen_t(MemoryLayout.size) + ) + setsockopt( + clientSocket, SOL_SOCKET, SO_SNDTIMEO, &interval, + socklen_t(MemoryLayout.size) + ) + let flags = fcntl(pumpSocket, F_GETFL, 0) + if flags >= 0 { _ = fcntl(pumpSocket, F_SETFL, flags | O_NONBLOCK) } + libssh2_session_set_blocking(session, 0) + + let source = DispatchSource.makeTimerSource(queue: queue) + source.schedule(deadline: .now(), repeating: .milliseconds(2), leeway: .milliseconds(1)) + source.setEventHandler { [weak self] in self?.pump() } + timer = source + source.resume() + } + + public func close() { + queue.sync { closeOnQueue() } + } + + private func pump() { + guard !isClosed, let session, let channel else { return } + let highWaterMark = 2 * 1024 * 1024 + + if !socketEOF, toChannel.count < highWaterMark { + var bytes = [UInt8](repeating: 0, count: 32 * 1024) + let count = Darwin.recv(pumpSocket, &bytes, bytes.count, 0) + if count > 0 { + toChannel.append(contentsOf: bytes.prefix(count)) + } else if count == 0 { + socketEOF = true + } else if errno != EAGAIN && errno != EWOULDBLOCK { + closeOnQueue() + return + } + } + + while !toChannel.isEmpty { + let written = toChannel.withUnsafeBytes { bytes -> Int in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } + return libssh2_channel_write_ex(channel, 0, base, bytes.count) + } + if written > 0 { + toChannel.removeFirst(written) + } else if written == LIBSSH2_ERROR_EAGAIN { + break + } else { + closeOnQueue() + return + } + } + if socketEOF, toChannel.isEmpty, !channelWriteClosed { + let result = libssh2_channel_send_eof(channel) + if result == 0 { + channelWriteClosed = true + } else if result != LIBSSH2_ERROR_EAGAIN { + closeOnQueue() + return + } + } + + if toSocket.count < highWaterMark { + var bytes = [CChar](repeating: 0, count: 32 * 1024) + while toSocket.count < highWaterMark { + let count = libssh2_channel_read_ex(channel, 0, &bytes, bytes.count) + if count > 0 { + toSocket.append(Data(bytes: bytes, count: count)) + } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { + break + } else { + closeOnQueue() + return + } + } + } + if libssh2_channel_eof(channel) == 1 { channelEOF = true } + + while !toSocket.isEmpty { + let sent = toSocket.withUnsafeBytes { bytes -> Int in + guard let base = bytes.baseAddress else { return 0 } + return Darwin.send(pumpSocket, base, bytes.count, 0) + } + if sent > 0 { + toSocket.removeFirst(sent) + } else if errno == EAGAIN || errno == EWOULDBLOCK { + break + } else { + closeOnQueue() + return + } + } + if channelEOF && toSocket.isEmpty { + _ = Darwin.shutdown(pumpSocket, SHUT_WR) + } + var next = Int32(0) + _ = libssh2_keepalive_send(session, &next) + } + + private func closeOnQueue() { + guard !isClosed else { return } + isClosed = true + timer?.cancel() + timer = nil + if pumpSocket >= 0 { + Darwin.close(pumpSocket) + pumpSocket = -1 + } + if let channel { + _ = libssh2_channel_close(channel) + _ = libssh2_channel_free(channel) + self.channel = nil + } + if let session { + _ = libssh2_session_disconnect_ex( + session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the jump route", "" + ) + _ = libssh2_session_free(session) + self.session = nil + } + if transportSocket >= 0 { + Darwin.close(transportSocket) + transportSocket = -1 + } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRuntimeVersions.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRuntimeVersions.swift new file mode 100644 index 00000000..51ab9ffa --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRuntimeVersions.swift @@ -0,0 +1,12 @@ +import CSSH2 +import OpenSSL + +public nonisolated enum SSHRuntimeVersions { + public static var libssh2: String { + guard let version = libssh2_version(0) else { return "Unknown" } + return String(cString: version) + } + public static var openSSL: String { + "\(OPENSSL_version_major()).\(OPENSSL_version_minor()).\(OPENSSL_version_patch())" + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift new file mode 100644 index 00000000..d767f9af --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHSessionActor.swift @@ -0,0 +1,1347 @@ +import CSSH2 +import Darwin +import Foundation +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +/// Installs a small cwd-reporting hook before remote output reaches Ghostty. +/// The probe, its echo, and the installation command stay inside this state +/// machine; only the login banner and the final integrated prompt are emitted. +public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { + public nonisolated struct Update: Sendable { + public var command: Data? + public var visibleOutput: Data? + public var isComplete = false + public var integrationInstalled = false + public var shell: String? + public var systemInfo: RemoteSystemInfo? + public var homeDirectory: String? + public var homeResolutionUnavailable = false + + } + + private enum Phase: Sendable { + case probing + case scanningForLateProbe + case suppressingLateProbe + case installing + case complete + } + + private static let responseTimeout: UInt64 = 3_000_000_000 + private static let quietTimeout: UInt64 = 500_000_000 + + public let token: String + public let startCommand: Data + private var deadline: UInt64 + private var phase: Phase = .probing + private var buffer = Data() + private var preservedOutput = Data() + private var lastActivity: UInt64? + private var userDidWrite = false + private var installationStartedLate = false + private var detectedShell: String? + private var detectedSystemInfo: RemoteSystemInfo? + + public init( + token: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""), + now: UInt64 = DispatchTime.now().uptimeNanoseconds + ) { + self.token = token + // The POSIX helper reads os-release as inert assignment text instead of + // sourcing it. Its output precedes the shell marker, so a missing `sh` + // still leaves the original shell-only bootstrap path usable. + startCommand = Data( + (" command sh -c 'f=; did=; dlike=; dver=; " + + "[ -r /etc/os-release ] && f=/etc/os-release; " + + "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; " + + "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in " + + "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; " + + "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; " + + "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); " + + "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); " + + "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" " + + "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; " + + "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n").utf8 + ) + deadline = now + Self.responseTimeout + } + + public mutating func noteUserInput() { + userDidWrite = true + } + + public mutating func receive( + _ data: Data, now: UInt64 = DispatchTime.now().uptimeNanoseconds + ) -> Update { + guard phase != .complete else { + return Update(visibleOutput: data, isComplete: true) + } + buffer.append(data) + switch phase { + case .probing: + return receiveProbe(now: now, isLate: false) + case .scanningForLateProbe: + return scanForLateProbe(now: now) + case .suppressingLateProbe: + lastActivity = now + return receiveProbe(now: now, isLate: true) + case .installing: + return receiveInstallation() + case .complete: + return Update(visibleOutput: data, isComplete: true) + } + } + + /// Advances time-dependent bootstrap work. The first probe timeout restores + /// the terminal but deliberately keeps a streaming signature scanner alive: + /// a slow remote may echo the probe only after the UI is interactive again. + public mutating func poll( + now: UInt64 = DispatchTime.now().uptimeNanoseconds + ) -> Update { + switch phase { + case .probing where now >= deadline: + let visible = cleanBufferedOutput() + phase = .scanningForLateProbe + lastActivity = nil + return Update(visibleOutput: visible, homeResolutionUnavailable: true) + case .scanningForLateProbe: + guard let lastActivity, now >= lastActivity + Self.quietTimeout, + !buffer.isEmpty + else { return Update() } + let visible = buffer + buffer.removeAll(keepingCapacity: false) + self.lastActivity = nil + return Update(visibleOutput: visible) + case .suppressingLateProbe: + guard + now >= deadline + || lastActivity.map({ now >= $0 + Self.quietTimeout }) == true + else { return Update() } + let visible = outputAfterLastLineBreak(in: buffer) + finish() + return Update(visibleOutput: visible, isComplete: true) + case .installing where now >= deadline: + let visible = + installationStartedLate + ? outputAfterLastLineBreak(in: buffer) + : cleanBufferedOutput() + finish() + return Update(visibleOutput: visible, isComplete: true) + case .probing, .installing, .complete: + return Update() + } + } + + private mutating func receiveProbe(now: UInt64, isLate: Bool) -> Update { + let prefix = Data("__TRMD_\(token)_SHELL__".utf8) + guard let prefixRange = buffer.range(of: prefix) else { return Update() } + let shellStart = prefixRange.upperBound + let terminator = Data("__".utf8) + guard + let terminatorRange = buffer.range( + of: terminator, in: shellStart.. Update { + let marker = Data("__TRMD_\(token)_DONE__".utf8) + guard let markerRange = buffer.range(of: marker), + let lineEnd = buffer[markerRange.upperBound...].firstIndex(of: 0x0A) + else { return Update() } + + var visible = preservedOutput + visible.append(Data(buffer[buffer.index(after: lineEnd)...])) + let homeDirectory = parseHomeDirectory(in: buffer) + finish() + return Update( + visibleOutput: visible, isComplete: true, + integrationInstalled: true, shell: detectedShell, + systemInfo: detectedSystemInfo, + homeDirectory: homeDirectory + ) + } + + private func parseHomeDirectory(in data: Data) -> String? { + let prefix = Data("__TRMD_\(token)_HOME__".utf8) + guard let prefixRange = data.range(of: prefix), + let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) + else { return nil } + var payload = Data(data[prefixRange.upperBound.. RemoteSystemInfo? { + let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) + guard let prefixRange = data.range(of: prefix), + let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) + else { return nil } + + var payload = Data(data[prefixRange.upperBound..= 2, payload.suffix(2).elementsEqual(Data("__".utf8)) + else { return nil } + payload.removeLast(2) + return RemoteSystemInfo(probeFields: String(decoding: payload, as: UTF8.self)) + } + + private func removingSystemInfoMarker(from data: Data) -> Data { + let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) + guard let prefixRange = data.range(of: prefix), + let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) + else { return data } + var result = Data(data[.. Update { + let echo = Data(startCommand.dropFirst().dropLast()) + let marker = Data("__TRMD_\(token)_SHELL__".utf8) + let targets = [echo, marker] + + if let match = earliestMatch(of: targets, in: buffer) { + var suppressionStart = match.range.lowerBound + if match.targetIndex == 0, suppressionStart > buffer.startIndex { + let preceding = buffer.index(before: suppressionStart) + if buffer[preceding] == 0x20 { suppressionStart = preceding } + } + let visible = Data(buffer[.. 0, buffer.count > retainedCount { + let retainedStart = buffer.index(buffer.endIndex, offsetBy: -retainedCount) + let preceding = buffer.index(before: retainedStart) + if buffer[preceding] == 0x20 { retainedCount += 1 } + } + let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retainedCount) + let visible = Data(buffer[.. (range: Range, targetIndex: Int)? + { + targets.enumerated().compactMap { index, target in + data.range(of: target).map { (range: $0, targetIndex: index) } + }.min { lhs, rhs in lhs.range.lowerBound < rhs.range.lowerBound } + } + + private func longestSuffixMatchingPrefix(of targets: [Data], in data: Data) -> Int { + let maximum = targets.map(\.count).max() ?? 0 + guard !data.isEmpty, maximum > 1 else { return 0 } + for count in stride(from: min(data.count, maximum - 1), through: 1, by: -1) { + if targets.contains(where: { target in + target.count >= count && data.suffix(count).elementsEqual(target.prefix(count)) + }) { + return count + } + } + return 0 + } + + private func mergingVisible(_ visible: Data, into update: Update) -> Update { + guard !visible.isEmpty else { return update } + var combined = visible + if let following = update.visibleOutput { combined.append(following) } + return Update( + command: update.command, visibleOutput: combined, + isComplete: update.isComplete, + integrationInstalled: update.integrationInstalled, + shell: update.shell, systemInfo: update.systemInfo, + homeDirectory: update.homeDirectory, + homeResolutionUnavailable: update.homeResolutionUnavailable + ) + } + + private mutating func cleanBufferedOutput() -> Data { + var visible = preservedOutput + if visible.isEmpty { visible = outputBeforeCommand(in: buffer) } + let suffix = outputAfterLastLineBreak(in: buffer) + if !suffix.isEmpty { visible.append(suffix) } + buffer.removeAll(keepingCapacity: false) + preservedOutput.removeAll(keepingCapacity: false) + return visible + } + + private func outputAfterLastLineBreak(in data: Data) -> Data { + guard let lineEnd = data.lastIndex(of: 0x0A), + data.index(after: lineEnd) < data.endIndex + else { return Data() } + return Data(data[data.index(after: lineEnd)...]) + } + + private mutating func finish() { + phase = .complete + buffer.removeAll(keepingCapacity: false) + preservedOutput.removeAll(keepingCapacity: false) + lastActivity = nil + } + + private func outputBeforeCommand(in data: Data) -> Data { + let echo = Data(startCommand.dropLast()) + let boundary = data.range(of: echo)?.lowerBound ?? data.endIndex + let prefix = data[.. Data? { + let marker = + "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'" + let command: String + switch shell { + case "bash": + command = + " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then __termind_integration_token=\"\(token)\"; __termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; __termind_report_cwd() { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; __termind_report_prompt() { builtin local status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"; builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; __termind_command_ready=1; return \"$status\"; }; __termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"; __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; }; __termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; }; __termind_compose_draft() { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"; }; __termind_compose_clear() { READLINE_LINE=; READLINE_POINT=0; }; for __termind_map in emacs-standard vi-insertion vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done; builtin unset __termind_map; if [[ -n \"${bash_preexec_imported:-}\" ]]; then [[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command); [[ \" ${precmd_functions[*]} \" == *\" __termind_report_prompt \"* ]] || precmd_functions=(__termind_report_prompt \"${precmd_functions[@]}\"); else __termind_debug_trap=\"$(__termind_get_trap DEBUG)\"; if [[ \"$__termind_debug_trap\" != *\"__termind_report_command\"* ]]; then if [[ -n \"$__termind_debug_trap\" ]]; then builtin trap \"$__termind_debug_trap\"$'\\n''__termind_report_command' DEBUG; else builtin trap '__termind_report_command' DEBUG; fi; fi; if [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then [[ \" ${PROMPT_COMMAND[*]} \" == *\" __termind_report_prompt \"* ]] || PROMPT_COMMAND=(__termind_report_prompt \"${PROMPT_COMMAND[@]}\"); elif [[ \"${PROMPT_COMMAND:-}\" != __termind_report_prompt* ]]; then PROMPT_COMMAND=\"__termind_report_prompt${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"; fi; fi; fi; \(marker); __termind_report_prompt\n" + case "zsh": + // Unlike bash, zsh reserves `status` as a read-only special parameter. + // Assigning it aborts this function and the remaining prompt hooks. + command = + " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n" + case "fish": + command = + " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n" + default: + return nil + } + return Data(command.utf8) + } +} + +/// Removes authenticated command-report OSC sequences before output reaches +/// Ghostty and emits their decoded payloads separately. The session token makes +/// ordinary OSC output in a remote program ineligible to forge local history. +public nonisolated struct RemoteCommandEventFilter: Sendable { + public static let maximumPayloadBytes = 64 * 1024 + + public nonisolated enum Event: Sendable, Equatable { + case commandStarted(String) + case commandFinished(Int32, duration: TimeInterval?) + case promptReady + case draft(String) + } + + public nonisolated struct Update: Sendable { + public var visibleOutput = Data() + public var events: [Event] = [] + + } + + private let commandPrefix: Data + private let commandFinishedPrefix: Data + private let promptPrefix: Data + private let draftPrefix: Data + private var buffer = Data() + + public init(token: String) { + commandPrefix = Data("\u{1B}]777;TermindCommand=\(token);".utf8) + commandFinishedPrefix = Data("\u{1B}]777;TermindCommandFinished=\(token);".utf8) + promptPrefix = Data("\u{1B}]777;TermindPrompt=\(token)".utf8) + draftPrefix = Data("\u{1B}]777;TermindDraft=\(token);".utf8) + } + + public mutating func receive(_ data: Data) -> Update { + buffer.append(data) + var update = Update() + while let match = earliestMarker() { + let marker = match.range + if marker.lowerBound > buffer.startIndex { + update.visibleOutput.append(buffer[.. Self.maximumPayloadBytes + { + // A forged or truncated authenticated marker must not retain + // the terminal stream forever. Drop the malformed frame and + // resume with the next received bytes. + buffer.removeAll(keepingCapacity: false) + } else { + buffer = Data(buffer[marker.lowerBound...]) + } + return update + } + let payload = Data(buffer[marker.upperBound.. Int { + let prefixes = [commandPrefix, commandFinishedPrefix, promptPrefix, draftPrefix] + guard !buffer.isEmpty else { return 0 } + let maximum = prefixes.map(\.count).max() ?? 0 + guard maximum > 1 else { return 0 } + for count in stride(from: min(buffer.count, maximum - 1), through: 1, by: -1) { + if prefixes.contains(where: { + $0.count >= count && buffer.suffix(count).elementsEqual($0.prefix(count)) + }) { + return count + } + } + return 0 + } + + private enum MarkerKind { case command, commandFinished, prompt, draft } + + private func earliestMarker() -> (range: Range, kind: MarkerKind)? { + let candidates: [(Range, MarkerKind)] = [ + buffer.range(of: commandPrefix).map { ($0, .command) }, + buffer.range(of: commandFinishedPrefix).map { ($0, .commandFinished) }, + buffer.range(of: promptPrefix).map { ($0, .prompt) }, + buffer.range(of: draftPrefix).map { ($0, .draft) }, + ].compactMap { $0 } + return candidates.min { $0.0.lowerBound < $1.0.lowerBound } + } + + public static func decodePercentEncoded(_ data: Data) -> String? { + let bytes = Array(data) + var decoded: [UInt8] = [] + decoded.reserveCapacity(bytes.count) + var index = 0 + while index < bytes.count { + if bytes[index] == 0x25, index + 2 < bytes.count, + let high = hex(bytes[index + 1]), let low = hex(bytes[index + 2]) + { + decoded.append(high << 4 | low) + index += 3 + } else { + decoded.append(bytes[index]) + index += 1 + } + } + return String(data: Data(decoded), encoding: .utf8) + } + + private static func hex(_ byte: UInt8) -> UInt8? { + switch byte { + case 0x30...0x39: byte - 0x30 + case 0x41...0x46: byte - 0x41 + 10 + case 0x61...0x66: byte - 0x61 + 10 + default: nil + } + } + + private static func duration(milliseconds text: Substring) -> TimeInterval? { + guard let milliseconds = UInt64(text), milliseconds <= 31_536_000_000 else { + return nil + } + return Double(milliseconds) / 1_000 + } +} + +public nonisolated enum SSHShellIntegrationEvent: Sendable { + case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?) + case systemInfo(RemoteSystemInfo) + case homeResolutionUnavailable + case promptReady + case commandStarted(String) + case commandFinished(Int32, duration: TimeInterval?) + case draft(String) +} + +public actor SSHSessionActor { + public typealias OutputHandler = @Sendable (Data) -> Void + public typealias ExitHandler = @Sendable (Result) -> Void + public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void + + private let worker: SSHSessionWorker + + public init( + output: @escaping OutputHandler, exit: @escaping ExitHandler, + shellIntegration: @escaping ShellIntegrationHandler = { _ in } + ) { + worker = SSHSessionWorker( + output: output, exit: exit, shellIntegrationHandler: shellIntegration + ) + } + + public static func discoverHostKey( + hostID: Host.ID, host: String, port: Int, proxy: SSHTransportProxy? = nil, + jumps: [SSHRouteHopConfiguration] = [], + timeout: TimeInterval = 15, + progress: SSHRouteProgressHandler? = nil + ) async throws + -> SSHHostKeyObservation + { + try await SSHSessionWorker.discoverHostKey( + hostID: hostID, host: host, port: port, proxy: proxy, jumps: jumps, + timeout: timeout, progress: progress + ) + } + + /// Appends a named public key to a remote account's `authorized_keys`, unless + /// the exact key line is already present. The generated comment and key travel + /// over the channel's stdin, never as shell syntax. + public static func installAuthorizedKey( + _ publicKey: String, named keyName: String, + using configuration: SSHConnectionConfiguration + ) async throws { + try await SSHSessionWorker.installAuthorizedKey( + publicKey, named: keyName, using: configuration + ) + } + + public func connect( + hostID: Host.ID, + host: String, + port: Int, + username: String, + credentials: [SSHCredentials], + expectedHostKeys: Set, + proxy: SSHTransportProxy? = nil, + jumps: [SSHRouteHopConfiguration] = [], + shellIntegrationEnabled: Bool = true, + columns: UInt16, + rows: UInt16, + progress: SSHRouteProgressHandler? = nil + ) async throws { + try await worker.connect( + hostID: hostID, host: host, port: port, username: username, + credentials: credentials, expectedHostKeys: expectedHostKeys, + proxy: proxy, jumps: jumps, + shellIntegrationEnabled: shellIntegrationEnabled, + columns: columns, rows: rows, progress: progress + ) + } + + public func write(_ data: Data) { + worker.write(data) + } + + public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { + worker.resize( + columns: columns, rows: rows, + widthPixels: widthPixels, heightPixels: heightPixels + ) + } + + public func disconnect() { + worker.disconnect() + } +} + +public nonisolated final class SSHSessionWorker: @unchecked Sendable { + private static let initialization: Void = { + precondition(libssh2_init(0) == 0, "libssh2 initialization failed") + }() + + private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.session") + private let output: SSHSessionActor.OutputHandler + private let exit: SSHSessionActor.ExitHandler + private let shellIntegrationHandler: SSHSessionActor.ShellIntegrationHandler + + private var socket: Int32 = -1 + private var routeTransport: SSHRoutedSocket? + private var session: OpaquePointer? + private var channel: OpaquePointer? + private var pumpTimer: (any DispatchSourceTimer)? + private var pendingWrites = Data() + private var shellIntegration: RemoteShellIntegrationBootstrap? + private var commandEventFilter: RemoteCommandEventFilter? + private var didReportExit = false + + public init( + output: @escaping SSHSessionActor.OutputHandler, + exit: @escaping SSHSessionActor.ExitHandler, + shellIntegrationHandler: @escaping SSHSessionActor.ShellIntegrationHandler + ) { + _ = Self.initialization + self.output = output + self.exit = exit + self.shellIntegrationHandler = shellIntegrationHandler + } + + public static func discoverHostKey( + hostID: Host.ID, host: String, port: Int, proxy: SSHTransportProxy?, + jumps: [SSHRouteHopConfiguration], timeout: TimeInterval, + progress: SSHRouteProgressHandler? + ) async throws + -> SSHHostKeyObservation + { + _ = initialization + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + var transport: SSHRoutedSocket? + var session: OpaquePointer? + do { + transport = try SSHRoutedSocket.open( + host: host, port: port, proxy: proxy, + jumps: jumps, timeout: timeout, progress: progress + ) + progress?(.connecting(hostID)) + guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw TermindSSHError.library("Unable to create an SSH session.") + } + session = created + libssh2_session_set_blocking(created, 1) + guard let transport, + libssh2_session_handshake(created, transport.socket) == 0 + else { + throw sessionError(created, prefix: "SSH handshake failed") + } + continuation.resume(returning: try observation(host: host, port: port, session: created)) + } catch { + continuation.resume(throwing: error) + } + if let session { + _ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "probe complete", "") + _ = libssh2_session_free(session) + } + transport?.close() + } + } + } + + /// Opens a transport, handshakes, validates the host key, and authenticates — + /// the identical prologue every non-PTY entry point needs before it can open + /// a channel. On any failure it frees what it created and rethrows, so a + /// caller only owns the pair once it has been handed one. + /// + /// The returned session is left in blocking mode. Callers that want the + /// non-blocking pump switch it themselves once their channel is running. + public static func openAuthenticatedSession( + using configuration: SSHConnectionConfiguration, + timeout: TimeInterval, + progress: SSHRouteProgressHandler? = nil + ) throws -> (transport: SSHRoutedSocket, session: OpaquePointer) { + _ = initialization + var transport: SSHRoutedSocket? + var session: OpaquePointer? + do { + let opened = try SSHRoutedSocket.open( + host: configuration.host, port: configuration.port, + proxy: configuration.proxy, jumps: configuration.jumps, + timeout: timeout, progress: progress + ) + transport = opened + progress?(.connecting(configuration.hostID)) + guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw TermindSSHError.library("Unable to create an SSH session.") + } + session = created + libssh2_session_set_blocking(created, 1) + guard libssh2_session_handshake(created, opened.socket) == 0 else { + throw sessionError(created, prefix: "SSH handshake failed") + } + let observed = try observation( + host: configuration.host, port: configuration.port, session: created + ) + try validateHostKey(observed, expected: configuration.expectedHostKeys) + progress?(.authenticating(configuration.hostID)) + try authenticate( + username: configuration.username, + credentials: configuration.credentials, + session: created + ) + progress?(.authenticated(configuration.hostID)) + return (opened, created) + } catch { + if let session { _ = libssh2_session_free(session) } + transport?.close() + throw error + } + } + + public static func installAuthorizedKey( + _ publicKey: String, named keyName: String, + using configuration: SSHConnectionConfiguration + ) async throws { + _ = initialization + let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName) + + try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + do { + try installAuthorizedKeyBlocking( + input, using: configuration + ) + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// A short-lived exec channel rather than a PTY. The fixed script creates + /// the OpenSSH directory with restrictive permissions and performs an exact + /// line check before appending, making repeated exports idempotent. + private static func installAuthorizedKeyBlocking( + _ input: Data, using configuration: SSHConnectionConfiguration + ) throws { + let (transport, created) = try openAuthenticatedSession( + using: configuration, timeout: 20 + ) + var channel: OpaquePointer? + defer { + if let channel { + _ = libssh2_channel_send_eof(channel) + _ = libssh2_channel_close(channel) + _ = libssh2_channel_free(channel) + } + _ = libssh2_session_disconnect_ex( + created, SSH_DISCONNECT_BY_APPLICATION, "Termind completed key export", "" + ) + _ = libssh2_session_free(created) + transport.close() + } + + guard + let opened = libssh2_channel_open_ex( + created, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0 + ) + else { + throw sessionError(created, prefix: "Unable to open SSH channel") + } + channel = opened + + let command = + #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"# + let started = command.withCString { commandBuffer in + libssh2_channel_process_startup( + opened, "exec", 4, commandBuffer, UInt32(command.utf8.count) + ) + } + guard started == 0 else { + throw sessionError(created, prefix: "Unable to start authorized_keys update") + } + + try input.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } + var offset = 0 + while offset < bytes.count { + let written = libssh2_channel_write_ex( + opened, 0, base.advanced(by: offset), bytes.count - offset + ) + guard written > 0 else { + throw sessionError(created, prefix: "Unable to send the public key") + } + offset += written + } + } + guard libssh2_channel_send_eof(opened) == 0 else { + throw sessionError(created, prefix: "Unable to finish the public-key upload") + } + _ = libssh2_channel_wait_eof(opened) + + let standardError = readAll(from: opened, stream: 1) + let status = libssh2_channel_get_exit_status(opened) + guard status == 0 else { + let detail = standardError.trimmingCharacters(in: .whitespacesAndNewlines) + throw TermindSSHError.library( + detail.isEmpty + ? "The remote authorized_keys update exited with status \(status)." + : "The remote authorized_keys update failed: \(detail)" + ) + } + } + + public static func authorizedKeyInstallInput(publicKey: String, keyName: String) throws -> Data { + let normalizedKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) + guard + !normalizedKey.isEmpty, !normalizedKey.contains("\n"), + !normalizedKey.contains("\r") + else { + throw TermindSSHError.library("The public key is not a single OpenSSH key line.") + } + + let printableName = keyName.unicodeScalars + .map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) } + .joined() + let normalizedName = + printableName + .split(whereSeparator: \Character.isWhitespace) + .joined(separator: " ") + let safeName = normalizedName.isEmpty ? "Key" : normalizedName + return Data(("# \(safeName) Key by Termind\n\(normalizedKey)\n").utf8) + } + + private static func readAll(from channel: OpaquePointer, stream: Int32) -> String { + var data = Data() + var buffer = [CChar](repeating: 0, count: 4 * 1024) + while true { + let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count) + guard count > 0 else { break } + data.append(Data(bytes: buffer, count: count)) + } + return String(decoding: data, as: UTF8.self) + } + + public func connect( + hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials], + expectedHostKeys: Set, proxy: SSHTransportProxy?, + jumps: [SSHRouteHopConfiguration], + shellIntegrationEnabled: Bool, + columns: UInt16, rows: UInt16, + progress: SSHRouteProgressHandler? + ) async throws { + try await withCheckedThrowingContinuation { continuation in + queue.async { [self] in + do { + cleanup() + routeTransport = try SSHRoutedSocket.open( + host: host, port: port, proxy: proxy, + jumps: jumps, timeout: 20, progress: progress + ) + progress?(.connecting(hostID)) + guard let routeTransport else { throw TermindSSHError.disconnected } + socket = routeTransport.socket + guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { + throw TermindSSHError.library("Unable to create an SSH session.") + } + session = created + libssh2_session_set_blocking(created, 1) + guard libssh2_session_handshake(created, socket) == 0 else { + throw Self.sessionError(created, prefix: "SSH handshake failed") + } + let observed = try Self.observation(host: host, port: port, session: created) + try Self.validateHostKey(observed, expected: expectedHostKeys) + progress?(.authenticating(hostID)) + try Self.authenticate(username: username, credentials: credentials, session: created) + progress?(.authenticated(hostID)) + guard + let opened = libssh2_channel_open_ex( + created, "session", 7, + 2 * 1024 * 1024, + 32 * 1024, nil, 0 + ) + else { + throw Self.sessionError(created, prefix: "Unable to open SSH channel") + } + channel = opened + let pty = libssh2_channel_request_pty_ex( + opened, "xterm-256color", 14, nil, 0, + Int32(columns), Int32(rows), 0, 0 + ) + guard pty == 0 else { throw Self.sessionError(created, prefix: "PTY request failed") } + guard libssh2_channel_process_startup(opened, "shell", 5, nil, 0) == 0 else { + throw Self.sessionError(created, prefix: "Remote shell request failed") + } + libssh2_session_set_blocking(created, 0) + libssh2_keepalive_config(created, 1, 20) + didReportExit = false + if shellIntegrationEnabled { + let bootstrap = RemoteShellIntegrationBootstrap() + pendingWrites.append(bootstrap.startCommand) + commandEventFilter = RemoteCommandEventFilter(token: bootstrap.token) + shellIntegration = bootstrap + } + startPump() + progress?(.connected(hostID)) + continuation.resume() + } catch { + cleanup() + continuation.resume(throwing: error) + } + } + } + } + + public func write(_ data: Data) { + guard !data.isEmpty else { return } + queue.async { [self] in + if var integration = shellIntegration { + integration.noteUserInput() + shellIntegration = integration + } + pendingWrites.append(data) + pump() + } + } + + public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { + guard columns > 0, rows > 0 else { return } + queue.async { [self] in + guard let channel else { return } + _ = libssh2_channel_request_pty_size_ex( + channel, Int32(columns), Int32(rows), + Int32(clamping: widthPixels), Int32(clamping: heightPixels) + ) + } + } + + public func disconnect() { + queue.async { [self] in + cleanup() + } + } + + public static func authenticate( + username: String, credentials: [SSHCredentials], session: OpaquePointer + ) throws { + let methods = try authenticationMethods(username: username, session: session) + // Asking for the method list sends SSH's `none` authentication request. + // Servers that need no credential accept it there and report the + // session as authenticated without another userauth call. + if libssh2_userauth_authenticated(session) == 1 { return } + var attempted = false + var secureEnclaveError: (any Error)? + for credentials in credentials { + switch credentials { + case .password(let password): + if methods.contains("password") { + attempted = true + let result = username.withCString { user in + password.withCString { password in + libssh2_userauth_password_ex( + session, user, UInt32(username.utf8.count), + password, UInt32(strlen(password)), nil + ) + } + } + if result == 0 { return } + } + if methods.contains("keyboard-interactive") { + attempted = true + if keyboardInteractive( + username: username, password: password, session: session + ) == 0 { + return + } + } + case .privateKey(let privateKey, let publicKey, let passphrase): + guard methods.contains("publickey") else { continue } + attempted = true + let result = username.withCString { user in + privateKey.withCString { privateBuffer in + let call: (UnsafePointer?) -> Int32 = { publicBuffer in + if let passphrase { + return passphrase.withCString { passphraseBuffer in + libssh2_userauth_publickey_frommemory( + session, user, username.utf8.count, + publicBuffer, publicKey?.utf8.count ?? 0, + privateBuffer, privateKey.utf8.count, passphraseBuffer + ) + } + } + return libssh2_userauth_publickey_frommemory( + session, user, username.utf8.count, + publicBuffer, publicKey?.utf8.count ?? 0, + privateBuffer, privateKey.utf8.count, nil + ) + } + return publicKey?.withCString(call) ?? call(nil) + } + } + if result == 0 { return } + case .secureEnclave(let credential, let publicKey): + guard methods.contains("publickey") else { continue } + guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else { + secureEnclaveError = TermindSSHError.library( + "The Secure Enclave key has an invalid SSH public key." + ) + continue + } + attempted = true + let signer = SecureEnclaveSigner(credential: credential) + var abstract: UnsafeMutableRawPointer? = Unmanaged.passUnretained(signer).toOpaque() + let result = username.withCString { user in + publicBlob.withUnsafeBytes { bytes in + libssh2_userauth_publickey( + session, + user, + bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), + bytes.count, + secureEnclaveSignCallback, + &abstract + ) + } + } + if result == 0 { return } + secureEnclaveError = signer.error + } + } + if let secureEnclaveError { + throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription) + } + let available = methods.sorted().joined(separator: ", ") + let prefix = + attempted + ? "SSH authentication failed" + : "No configured credential matches the server methods (\(available))" + throw TermindSSHError.authenticationFailed( + Self.sessionErrorMessage(session, prefix: prefix) + ) + } + + private static func authenticationMethods( + username: String, session: OpaquePointer + ) throws -> Set { + let pointer = username.withCString { user in + libssh2_userauth_list(session, user, UInt32(username.utf8.count)) + } + guard let pointer else { + if libssh2_userauth_authenticated(session) == 1 { return [] } + throw Self.sessionError( + session, prefix: "Unable to read SSH authentication methods" + ) + } + let bytes = UnsafeBufferPointer(start: pointer, count: strlen(pointer)) + .map { UInt8(bitPattern: $0) } + return Set(String(decoding: bytes, as: UTF8.self).split(separator: ",").map(String.init)) + } + + private static func keyboardInteractive( + username: String, password: String, session: OpaquePointer + ) -> Int32 { + let box = Unmanaged.passRetained(SSHKeyboardPassword(password)) + let abstract = libssh2_session_abstract(session) + abstract?.pointee = box.toOpaque() + defer { + abstract?.pointee = nil + box.release() + } + return username.withCString { user in + libssh2_userauth_keyboard_interactive_ex( + session, user, UInt32(username.utf8.count), sshKeyboardInteractiveResponse + ) + } + } + + private func startPump() { + pumpTimer?.cancel() + let timer = DispatchSource.makeTimerSource(queue: queue) + timer.schedule(deadline: .now(), repeating: .milliseconds(12), leeway: .milliseconds(4)) + timer.setEventHandler { [weak self] in self?.pump() } + pumpTimer = timer + timer.resume() + } + + private func pump() { + guard let session, let channel else { return } + var buffer = [CChar](repeating: 0, count: 32 * 1024) + while true { + let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count) + if count > 0 { + deliverOutput(Data(bytes: buffer, count: count)) + } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { + break + } else { + reportExit(.failure(Self.sessionError(session, prefix: "SSH read failed"))) + cleanup() + return + } + } + + if var integration = shellIntegration { + let update = integration.poll() + shellIntegration = update.isComplete ? nil : integration + applyShellIntegrationUpdate(update) + } + + while !pendingWrites.isEmpty { + let written = pendingWrites.withUnsafeBytes { bytes -> Int in + guard let address = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } + return libssh2_channel_write_ex(channel, 0, address, bytes.count) + } + if written > 0 { + pendingWrites.removeFirst(written) + } else if written == LIBSSH2_ERROR_EAGAIN { + break + } else { + reportExit(.failure(Self.sessionError(session, prefix: "SSH write failed"))) + cleanup() + return + } + } + + var next = Int32(0) + let keepalive = libssh2_keepalive_send(session, &next) + if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { + reportExit(.failure(Self.sessionError(session, prefix: "SSH keepalive failed"))) + cleanup() + return + } + if libssh2_channel_eof(channel) == 1 { + let status = libssh2_channel_get_exit_status(channel) + reportExit(.success(status)) + cleanup() + } + } + + private func reportExit(_ result: Result) { + guard !didReportExit else { return } + didReportExit = true + exit(result) + } + + private func deliverOutput(_ data: Data) { + guard var integration = shellIntegration else { + deliverIntegratedOutput(data) + return + } + let update = integration.receive(data) + shellIntegration = update.isComplete ? nil : integration + applyShellIntegrationUpdate(update) + } + + private func applyShellIntegrationUpdate( + _ update: RemoteShellIntegrationBootstrap.Update + ) { + if let command = update.command { + pendingWrites.append(command) + } + if update.integrationInstalled { + shellIntegrationHandler( + .available( + shell: update.shell ?? "unknown", systemInfo: update.systemInfo, + homeDirectory: update.homeDirectory + ) + ) + } else { + if let systemInfo = update.systemInfo { + shellIntegrationHandler(.systemInfo(systemInfo)) + } + if update.homeResolutionUnavailable || update.isComplete { + shellIntegrationHandler(.homeResolutionUnavailable) + } + } + if let visibleOutput = update.visibleOutput, !visibleOutput.isEmpty { + deliverIntegratedOutput(visibleOutput) + } + } + + private func deliverIntegratedOutput(_ data: Data) { + guard var commandEventFilter else { + output(data) + return + } + let update = commandEventFilter.receive(data) + self.commandEventFilter = commandEventFilter + if !update.visibleOutput.isEmpty { output(update.visibleOutput) } + for event in update.events { + switch event { + case .commandStarted(let command): + shellIntegrationHandler(.commandStarted(command)) + case .commandFinished(let status, let duration): + shellIntegrationHandler(.commandFinished(status, duration: duration)) + case .promptReady: + shellIntegrationHandler(.promptReady) + case .draft(let draft): + shellIntegrationHandler(.draft(draft)) + } + } + } + + private func cleanup() { + pumpTimer?.cancel() + pumpTimer = nil + pendingWrites.removeAll(keepingCapacity: false) + shellIntegration = nil + commandEventFilter = nil + if let channel { + _ = libssh2_channel_send_eof(channel) + _ = libssh2_channel_close(channel) + _ = libssh2_channel_free(channel) + self.channel = nil + } + if let session { + _ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the session", "") + _ = libssh2_session_free(session) + self.session = nil + } + if socket >= 0 { + socket = -1 + } + routeTransport?.close() + routeTransport = nil + } + + public static func observation(host: String, port: Int, session: OpaquePointer) throws + -> SSHHostKeyObservation + { + guard let hash = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_SHA256)) else { + throw sessionError(session, prefix: "The server did not provide a host-key fingerprint") + } + let digest = Data(bytes: hash, count: 32).base64EncodedString().replacingOccurrences(of: "=", with: "") + var length = 0 + var type = Int32(LIBSSH2_HOSTKEY_TYPE_UNKNOWN) + _ = libssh2_session_hostkey(session, &length, &type) + let algorithm: SSHKeyAlgorithm + switch type { + case LIBSSH2_HOSTKEY_TYPE_RSA: algorithm = .rsa + case LIBSSH2_HOSTKEY_TYPE_ECDSA_256: algorithm = .ecdsa256 + case LIBSSH2_HOSTKEY_TYPE_ECDSA_384: algorithm = .ecdsa384 + case LIBSSH2_HOSTKEY_TYPE_ECDSA_521: algorithm = .ecdsa521 + case LIBSSH2_HOSTKEY_TYPE_ED25519: algorithm = .ed25519 + default: + throw sessionError(session, prefix: "The server provided an unsupported host-key type") + } + return SSHHostKeyObservation( + hostname: host, port: port, algorithm: algorithm, + fingerprint: "SHA256:\(digest)" + ) + } + + public static func validateHostKey( + _ observed: SSHHostKeyObservation, expected: Set + ) throws { + guard expected.contains(observed.identity) else { + throw TermindSSHError.hostKeyChanged( + expected: Set(expected.map(\.fingerprint)).sorted(), + received: observed.fingerprint + ) + } + } + + public static func sessionError(_ session: OpaquePointer, prefix: String) -> TermindSSHError { + .library(sessionErrorMessage(session, prefix: prefix)) + } + + private static func sessionErrorMessage(_ session: OpaquePointer, prefix: String) -> String { + var message: UnsafeMutablePointer? + var length = Int32(0) + let code = libssh2_session_last_error(session, &message, &length, 0) + let detail = message.map { String(cString: $0) } ?? "libssh2 error \(code)" + return "\(prefix): \(detail)" + } + +} + +private nonisolated final class SecureEnclaveSigner: @unchecked Sendable { + public let credential: SecureEnclaveKeyCredential + public var error: (any Error)? + + public init(credential: SecureEnclaveKeyCredential) { + self.credential = credential + } +} + +private nonisolated let secureEnclaveSignCallback: + @convention(c) ( + OpaquePointer?, UnsafeMutablePointer?>?, + UnsafeMutablePointer?, UnsafePointer?, Int, + UnsafeMutablePointer? + ) -> Int32 = { _, signature, signatureLength, data, dataLength, abstract in + guard let opaqueSigner = abstract?.pointee, let data, dataLength > 0 else { return -1 } + let signer = Unmanaged.fromOpaque(opaqueSigner).takeUnretainedValue() + do { + let signedData = try SecureEnclaveKey.sign( + Data(bytes: data, count: dataLength), credential: signer.credential + ) + guard let output = malloc(signedData.count)?.assumingMemoryBound(to: UInt8.self) else { + return -1 + } + signedData.copyBytes(to: output, count: signedData.count) + signature?.pointee = output + signatureLength?.pointee = signedData.count + return 0 + } catch { + signer.error = error + return -1 + } + } + +private nonisolated final class SSHKeyboardPassword: @unchecked Sendable { + public let value: String + + public init(_ value: String) { + self.value = value + } +} + +private nonisolated func sshKeyboardInteractiveResponse( + _ name: UnsafePointer?, _ nameLength: Int32, + _ instruction: UnsafePointer?, _ instructionLength: Int32, + _ promptCount: Int32, + _ prompts: UnsafePointer?, + _ responses: UnsafeMutablePointer?, + _ abstract: UnsafeMutablePointer? +) { + guard promptCount > 0, let responses, let opaque = abstract?.pointee else { return } + let password = Unmanaged.fromOpaque(opaque) + .takeUnretainedValue().value + for index in 0.. Int32 { + let socket = try openTCP( + host: proxy?.hostname ?? host, + port: proxy?.port ?? port, + timeout: timeout + ) + guard let proxy else { return socket } + do { + try negotiate(socket: socket, host: host, port: port, proxy: proxy) + return socket + } catch { + Darwin.close(socket) + throw error + } + } + + /// Applies proxy negotiation to an already-open byte stream. Jump routing + /// uses this with the local end of a socketpair bridged to direct-tcpip. + public static func negotiate( + socket: Int32, host: String, port: Int, proxy: SSHTransportProxy + ) throws { + switch proxy.kind { + case .http: + try negotiateHTTP(socket: socket, host: host, port: port, proxy: proxy) + case .socks5: + try negotiateSOCKS5(socket: socket, host: host, port: port, proxy: proxy) + } + } + + private static func negotiateHTTP( + socket: Int32, host: String, port: Int, proxy: SSHTransportProxy + ) throws { + let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)" + var request = "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n" + if let password = proxy.password, !proxy.username.isEmpty { + let token = Data("\(proxy.username):\(password)".utf8).base64EncodedString() + request += "Proxy-Authorization: Basic \(token)\r\n" + } + request += "\r\n" + try writeAll(socket, Data(request.utf8)) + + var response = Data() + while response.range(of: Data("\r\n\r\n".utf8)) == nil { + guard response.count < 64 * 1024 else { + throw TermindSSHError.proxy("The HTTP proxy response headers are too large.") + } + // Stop exactly at the header terminator. A fast proxy may put the + // SSH banner in the same TCP packet; consuming it here would make + // the following libssh2 handshake wait for bytes already discarded. + response.append(try readSome(socket, maximum: 1)) + } + guard + let firstLine = String(decoding: response, as: UTF8.self) + .split(separator: "\n", maxSplits: 1).first + else { throw TermindSSHError.proxy("The HTTP proxy returned an empty response.") } + let fields = firstLine.split(separator: " ") + guard fields.count >= 2, let status = Int(fields[1]), (200..<300).contains(status) else { + let status = fields.count >= 2 ? String(fields[1]) : "invalid" + throw TermindSSHError.proxy("HTTP CONNECT failed with status \(status).") + } + } + + private static func negotiateSOCKS5( + socket: Int32, host: String, port: Int, proxy: SSHTransportProxy + ) throws { + let usesAuthentication = proxy.password != nil && !proxy.username.isEmpty + try writeAll(socket, Data(usesAuthentication ? [0x05, 0x01, 0x02] : [0x05, 0x01, 0x00])) + let selection = try readExactly(socket, count: 2) + guard selection[0] == 0x05, selection[1] != 0xff else { + throw TermindSSHError.proxy("The SOCKS5 proxy does not accept the configured authentication method.") + } + if selection[1] == 0x02 { + guard let password = proxy.password else { + throw TermindSSHError.proxy("The SOCKS5 proxy requires a password.") + } + let username = Data(proxy.username.utf8) + let secret = Data(password.utf8) + guard username.count <= 255, secret.count <= 255 else { + throw TermindSSHError.proxy("The SOCKS5 username or password is too long.") + } + var auth = Data([0x01, UInt8(username.count)]) + auth.append(username) + auth.append(UInt8(secret.count)) + auth.append(secret) + try writeAll(socket, auth) + let result = try readExactly(socket, count: 2) + guard result[1] == 0 else { + throw TermindSSHError.proxy("SOCKS5 proxy authentication failed.") + } + } else if selection[1] != 0x00 { + throw TermindSSHError.proxy("The SOCKS5 proxy selected an unsupported authentication method.") + } + + var request = Data([0x05, 0x01, 0x00]) + var ipv4 = in_addr() + var ipv6 = in6_addr() + if host.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { + request.append(0x01) + withUnsafeBytes(of: &ipv4) { request.append(contentsOf: $0) } + } else if host.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { + request.append(0x04) + withUnsafeBytes(of: &ipv6) { request.append(contentsOf: $0) } + } else { + let hostBytes = Data(host.utf8) + guard hostBytes.count <= 255 else { + throw TermindSSHError.proxy("The proxy target hostname is too long.") + } + request.append(0x03) + request.append(UInt8(hostBytes.count)) + request.append(hostBytes) + } + request.append(UInt8((port >> 8) & 0xff)) + request.append(UInt8(port & 0xff)) + try writeAll(socket, request) + + let response = try readExactly(socket, count: 4) + guard response[0] == 0x05, response[1] == 0 else { + throw TermindSSHError.proxy("SOCKS5 CONNECT failed with reply \(response[1]).") + } + let addressLength: Int + switch response[3] { + case 0x01: addressLength = 4 + case 0x04: addressLength = 16 + case 0x03: addressLength = Int(try readExactly(socket, count: 1)[0]) + default: throw TermindSSHError.proxy("The SOCKS5 proxy returned an invalid address type.") + } + _ = try readExactly(socket, count: addressLength + 2) + } + + private static func openTCP(host: String, port: Int, timeout: TimeInterval) throws -> Int32 { + var hints = addrinfo() + hints.ai_family = AF_UNSPEC + hints.ai_socktype = SOCK_STREAM + hints.ai_protocol = IPPROTO_TCP + var result: UnsafeMutablePointer? + let status = getaddrinfo(host, String(port), &hints, &result) + guard status == 0, let first = result else { + throw TermindSSHError.socket(String(cString: gai_strerror(status))) + } + defer { freeaddrinfo(first) } + + var cursor: UnsafeMutablePointer? = first + var lastError = String(localized: "Connection failed") + while let info = cursor?.pointee { + let candidate = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol) + if candidate >= 0 { + var yes: Int32 = 1 + setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size(ofValue: yes))) + var interval = timeval(tv_sec: Int(timeout), tv_usec: 0) + setsockopt(candidate, SOL_SOCKET, SO_RCVTIMEO, &interval, socklen_t(MemoryLayout.size)) + setsockopt(candidate, SOL_SOCKET, SO_SNDTIMEO, &interval, socklen_t(MemoryLayout.size)) + do { + try connect(candidate, address: info.ai_addr, length: info.ai_addrlen, timeout: timeout) + return candidate + } catch { lastError = error.localizedDescription } + Darwin.close(candidate) + } + cursor = info.ai_next + } + throw TermindSSHError.socket(lastError) + } + + private static func connect( + _ socket: Int32, address: UnsafePointer, length: socklen_t, + timeout: TimeInterval + ) throws { + let flags = fcntl(socket, F_GETFL, 0) + guard flags >= 0, fcntl(socket, F_SETFL, flags | O_NONBLOCK) == 0 else { + throw TermindSSHError.socket(String(cString: strerror(errno))) + } + defer { _ = fcntl(socket, F_SETFL, flags) } + if Darwin.connect(socket, address, length) == 0 { return } + guard errno == EINPROGRESS else { throw TermindSSHError.socket(String(cString: strerror(errno))) } + var descriptor = pollfd(fd: socket, events: Int16(POLLOUT), revents: 0) + let result = Darwin.poll(&descriptor, 1, Int32(max(1, timeout * 1000))) + guard result > 0 else { + throw TermindSSHError.socket( + result == 0 ? String(localized: "Connection timed out.") : String(cString: strerror(errno))) + } + var socketError: Int32 = 0 + var errorLength = socklen_t(MemoryLayout.size) + guard getsockopt(socket, SOL_SOCKET, SO_ERROR, &socketError, &errorLength) == 0, socketError == 0 else { + throw TermindSSHError.socket(String(cString: strerror(socketError == 0 ? errno : socketError))) + } + } + + private static func writeAll(_ socket: Int32, _ data: Data) throws { + try data.withUnsafeBytes { bytes in + guard let base = bytes.baseAddress else { return } + var offset = 0 + while offset < bytes.count { + let written = Darwin.send(socket, base.advanced(by: offset), bytes.count - offset, 0) + guard written > 0 else { throw TermindSSHError.proxy(String(cString: strerror(errno))) } + offset += written + } + } + } + + private static func readSome(_ socket: Int32, maximum: Int) throws -> Data { + var buffer = [UInt8](repeating: 0, count: maximum) + let count = Darwin.recv(socket, &buffer, buffer.count, 0) + guard count > 0 else { + throw TermindSSHError.proxy( + count == 0 ? "The proxy closed the connection." : String(cString: strerror(errno))) + } + return Data(buffer.prefix(count)) + } + + private static func readExactly(_ socket: Int32, count: Int) throws -> Data { + var result = Data() + while result.count < count { result.append(try readSome(socket, maximum: count - result.count)) } + return result + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift b/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift new file mode 100644 index 00000000..bb575de5 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/SSHImport/SSHImportReader.swift @@ -0,0 +1,226 @@ +import Foundation +import TermindApplication +import TermindDomain + +/// One import session owns its secrets. Only metadata leaves this actor until commit. +public actor SSHImportReader: SSHImportReading { + private let username: String + private let home: URL? + + public init(username: String, home: URL? = nil) { + self.username = username + self.home = home + } + + private struct Material { + var text: String + var certificate: String + } + private var materials: [UUID: Material] = [:] + private var stableIDs: [String: UUID] = [:] + + public func discard() { materials.removeAll() } + + public func scan(root: URL, knownHostsOnly: Bool, grants: [URL] = []) throws -> [SSHImportItem] { + let scopes = [root] + grants + let acquired = scopes.filter { $0.startAccessingSecurityScopedResource() } + defer { for url in acquired { url.stopAccessingSecurityScopedResource() } } + materials.removeAll() + let sourceType = try root.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) + guard knownHostsOnly ? sourceType.isRegularFile == true : sourceType.isDirectory == true else { + throw knownHostsOnly ? SSHImportSourceError.expectedFile : SSHImportSourceError.expectedFolder + } + if knownHostsOnly { + return stabilize(SSHImportParser.knownHosts(try read(root), source: root.path)) + } + let root = root.standardizedFileURL + let authorized = scopes.map { $0.resolvingSymlinksInPath().standardizedFileURL.path } + func allowed(_ url: URL) -> Bool { + let path = url.resolvingSymlinksInPath().standardizedFileURL.path + return authorized.contains { path == $0 || path.hasPrefix($0 + "/") } + } + var files: [String: String] = [:] + var inaccessible: [String] = [] + var fileCount = 0 + var totalBytes = 0 + let manager = FileManager.default + for scope in scopes { + var directory: ObjCBool = false + guard manager.fileExists(atPath: scope.path, isDirectory: &directory) else { continue } + let urls: [URL] + if directory.boolValue { + let enumerator = manager.enumerator( + at: scope, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: []) + urls = enumerator?.allObjects.compactMap { $0 as? URL } ?? [] + } else { + urls = [scope] + } + for url in urls { + let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) + guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue } + guard allowed(url) else { + inaccessible.append(url.path) + continue + } + fileCount += 1 + guard fileCount <= 4096 else { throw CocoaError(.fileReadTooLarge) } + do { + let value = try read(url) + totalBytes += value.utf8.count + guard totalBytes <= 64 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } + files[url.standardizedFileURL.path] = value + } catch { + inaccessible.append(url.path) + } + } + } + var parser = SSHConfigParser( + root: root, home: home ?? root.deletingLastPathComponent(), + username: username, files: files) + var items = parser.parse() + let knownPath = root.appendingPathComponent("known_hosts").path + if let known = files[knownPath] { items += SSHImportParser.knownHosts(known, source: knownPath) } + let hosts = items.compactMap(\.host) + let referenced = Set(hosts.flatMap(\.identityPaths)) + let privateFiles = files.keys.filter { isPrivateKey(files[$0] ?? "") } + let paths = Set(privateFiles).union(referenced.filter { files[$0] != nil }) + for path in paths.sorted() { + let content = files[path] ?? "" + guard isPrivateKey(content) else { + items.append( + SSHImportItem( + category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, + source: path, selected: false, + issue: String(localized: "No supported private key in this file."), unavailable: true)) + continue + } + let explicitCertificates = hosts.filter { $0.identityPaths.contains(path) }.flatMap(\.certificatePaths) + let certificatePaths = Set(explicitCertificates + [path + "-cert.pub"]) + var variants: [String: [String]] = [:] + for certificatePath in certificatePaths.sorted() { + if let certificate = files[certificatePath] { + variants[certificate, default: []].append(certificatePath) + } + } + if files[path + "-cert.pub"] == nil { variants[""] = [] } + for certificate in variants.keys.sorted() { + let stable = path + "\n" + certificate + let id = stableID(stable) + materials[id] = Material(text: content, certificate: certificate) + var item = SSHImportItem( + id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path) + item.certificatePaths = variants[certificate] ?? [] + do { + item.key = try inspect(id: id, name: item.name, passphrase: nil) + } catch { + if isEncrypted(content) { + item.requiresPassphrase = true + item.issue = String(localized: "Enter the key passphrase to validate this private key.") + } else { + item.selected = false + item.unavailable = true + item.issue = String(localized: "No supported private key in this file.") + } + } + items.append(item) + } + } + let missing = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter { files[$0] == nil } + let certificateMissing = hosts.flatMap(\.certificatePaths).filter { files[$0] == nil } + for path in Set(inaccessible).union(parser.externalPaths).union(missing).union(certificateMissing).sorted() { + items.append( + SSHImportItem( + category: path == knownPath ? .knownHosts : parser.externalPaths.contains(path) ? .hosts : .keys, + name: URL(fileURLWithPath: path).lastPathComponent, source: path, + issue: String(localized: "Authorize access to this referenced file, or deselect it."), + needsAuthorization: path)) + } + // Missing explicit references must also be visible on the affected host. + for index in items.indices { + guard let host = items[index].host else { continue } + let absent = (host.identityPaths + host.certificatePaths).filter { + files[$0] == nil && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0)) + } + items[index].warnings += absent.map { String(localized: "Referenced file is unavailable:") + " " + $0 } + } + return stabilize(items) + } + + public func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef { + try inspect(id: id, name: name, passphrase: passphrase) + } + + public func credentials(for ids: Set) throws -> [UUID: StoredSSHKeyCredential] { + var result: [UUID: StoredSSHKeyCredential] = [:] + for id in ids { + guard let material = materials[id] else { throw CocoaError(.fileReadUnknown) } + result[id] = .software(SSHPrivateKeySecret(privateKey: material.text)) + } + return result + } + + private func inspect(id: UUID, name: String, passphrase: String?) throws -> SSHKeyRef { + guard let input = materials[id] else { throw CocoaError(.fileReadUnknown) } + let material = try SSHKeyMaterial.inspect(privateKey: input.text, passphrase: passphrase) + if !input.certificate.isEmpty { + _ = try SSHUserCertificate.parse(input.certificate, matching: material.publicKey) + } + return SSHKeyRef( + id: id, label: name, algorithm: material.algorithm, + publicKey: material.publicKey, certificate: input.certificate, fingerprint: material.fingerprint, + credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize, + hasPassphrase: passphrase?.isEmpty == false, createdAt: Date()) + } + + private func stableID(_ source: String) -> UUID { + if let id = stableIDs[source] { return id } + let id = UUID() + stableIDs[source] = id + return id + } + + private func stabilize(_ values: [SSHImportItem]) -> [SSHImportItem] { + var occurrences: [String: Int] = [:] + return values.map { item in + var item = item + if materials[item.id] == nil { + let token = "\(item.category.rawValue):\(item.source):\(item.name)" + let occurrence = occurrences[token, default: 0] + occurrences[token] = occurrence + 1 + item.id = stableID(token + ":\(occurrence)") + } + return item + } + } + + private func read(_ url: URL) throws -> String { + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else { + throw CocoaError(.fileReadTooLarge) + } + return try String(contentsOf: url, encoding: .utf8) + } + + private func isEncrypted(_ text: String) -> Bool { + if text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") || text.contains("DEK-Info:") { return true } + guard text.hasPrefix("-----BEGIN OPENSSH PRIVATE KEY-----") else { return false } + let encoded = text.components(separatedBy: .newlines).filter { !$0.hasPrefix("-----") }.joined() + let magic = Data("openssh-key-v1\0".utf8) + guard let data = Data(base64Encoded: encoded), data.starts(with: magic), data.count >= magic.count + 4 else { + return false + } + let offset = magic.count + let length = data[offset.. Bool { + [ + "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----", + "-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----", + ].contains { text.hasPrefix($0) } + } + +} diff --git a/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift new file mode 100644 index 00000000..c9447a14 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Security/KeychainSecretStore.swift @@ -0,0 +1,206 @@ +import Foundation +import Security +import TermindApplication +import TermindDomain + +public nonisolated enum KeychainSecretError: LocalizedError { + case status(OSStatus) + case invalidReference + case unexpectedValue + + public var errorDescription: String? { + switch self { + case .status(let status): + (SecCopyErrorMessageString(status, nil) as String?) + ?? String(localized: "Keychain error \(status)") + case .invalidReference: + String(localized: "The Keychain reference has the wrong credential type.") + case .unexpectedValue: + String(localized: "The Keychain item does not contain the expected credential type.") + } + } +} + +/// The Vault's typed secret boundary. Keeping the store behind this small +/// interface lets connection tests prove password/passphrase lifetime rules +/// without depending on a signed Keychain-enabled test host. +/// Typed storage in the macOS Data Protection Keychain. Independent services +/// prevent a damaged reference from reinterpreting one credential as another. +public nonisolated struct KeychainSecretStore: SecretStoring, Sendable { + private static let passwordService = "com.akinokaede.termind.passwords.v1" + private static let softwareKeyService = "com.akinokaede.termind.ssh-software-keys.v1" + private static let enclaveService = "com.akinokaede.termind.secure-enclave-handles.v1" + + private let accessGroup: String + + public init(accessGroup: String) { + self.accessGroup = accessGroup + } + + public func string(for account: String, storage: PasswordStorage) throws -> String? { + try validateStringReference(account) + guard + let data = try data( + for: account, service: Self.passwordService, + synchronizable: storage == .iCloud + ) + else { return nil } + guard let value = String(data: data, encoding: .utf8) else { + throw KeychainSecretError.unexpectedValue + } + return value + } + + public func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { + try validateStringReference(account) + try setData( + value?.data(using: .utf8), for: account, service: Self.passwordService, + synchronizable: storage == .iCloud, + accessible: storage == .iCloud + ? kSecAttrAccessibleWhenUnlocked + : kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ) + } + + public func keyCredential(for account: String, storage: KeyStorage) throws + -> StoredSSHKeyCredential? + { + try validate(account, prefix: "termind.key.") + guard + let data = try data( + for: account, service: service(for: storage), + synchronizable: storage == .iCloud + ) + else { return nil } + switch storage { + case .thisDevice, .iCloud: + return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data)) + case .secureEnclave: + guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } + return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) + } + } + + public func setKeyCredential( + _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage + ) throws { + try validate(account, prefix: "termind.key.") + let data: Data? + switch (value, storage) { + case (.software(let secret), .thisDevice), (.software(let secret), .iCloud): + data = try JSONEncoder().encode(secret) + case (.secureEnclave(let credential), .secureEnclave): + data = credential.dataRepresentation + case (nil, _): + data = nil + default: + throw KeychainSecretError.unexpectedValue + } + try setData( + data, for: account, service: service(for: storage), + synchronizable: storage == .iCloud, + accessible: storage == .iCloud + ? kSecAttrAccessibleWhenUnlocked + : kSecAttrAccessibleWhenUnlockedThisDeviceOnly + ) + } + + public func removePassword( + _ account: String, storage: PasswordStorage = .thisDevice + ) throws { + guard !account.isEmpty else { return } + try validate(account, prefix: "termind.password.") + try remove( + account, service: Self.passwordService, + synchronizable: storage == .iCloud + ) + } + + public func removeKeyCredential(_ account: String, storage: KeyStorage) throws { + guard !account.isEmpty else { return } + try validate(account, prefix: "termind.key.") + try remove( + account, service: service(for: storage), + synchronizable: storage == .iCloud + ) + } + + private func data(for account: String, service: String, synchronizable: Bool) throws + -> Data? + { + guard !account.isEmpty else { return nil } + var query = baseQuery(account, service: service, synchronizable: synchronizable) + query[kSecMatchLimit as String] = kSecMatchLimitOne + query[kSecReturnData as String] = true + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { throw KeychainSecretError.status(status) } + guard let data = result as? Data else { throw KeychainSecretError.unexpectedValue } + return data + } + + private func setData( + _ value: Data?, for account: String, service: String, + synchronizable: Bool, accessible: CFString + ) throws { + guard let value else { + try remove(account, service: service, synchronizable: synchronizable) + return + } + let match = baseQuery(account, service: service, synchronizable: synchronizable) + let attributes: [String: Any] = [ + kSecValueData as String: value, + kSecAttrAccessible as String: accessible, + ] + let updateStatus = SecItemUpdate(match as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecSuccess { return } + guard updateStatus == errSecItemNotFound else { + throw KeychainSecretError.status(updateStatus) + } + var add = match + for (key, value) in attributes { add[key] = value } + let addStatus = SecItemAdd(add as CFDictionary, nil) + guard addStatus == errSecSuccess else { throw KeychainSecretError.status(addStatus) } + } + + private func remove(_ account: String, service: String, synchronizable: Bool) throws { + let query = baseQuery(account, service: service, synchronizable: synchronizable) + let status = SecItemDelete(query as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainSecretError.status(status) + } + } + + private func baseQuery( + _ account: String, service: String, synchronizable: Bool + ) -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecAttrAccessGroup as String: accessGroup, + kSecAttrSynchronizable as String: synchronizable, + kSecUseDataProtectionKeychain as String: true, + ] + } + + private func service(for storage: KeyStorage) -> String { + switch storage { + case .thisDevice, .iCloud: Self.softwareKeyService + case .secureEnclave: Self.enclaveService + } + } + + private func validate(_ account: String, prefix: String) throws { + guard account.hasPrefix(prefix), account.count > prefix.count else { + throw KeychainSecretError.invalidReference + } + } + + private func validateStringReference(_ account: String) throws { + let prefixes = ["termind.password.", "termind.ai."] + guard prefixes.contains(where: { account.hasPrefix($0) && account.count > $0.count }) + else { throw KeychainSecretError.invalidReference } + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift b/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift new file mode 100644 index 00000000..44dfe0cb --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Security/SecureEnclaveKey.swift @@ -0,0 +1,113 @@ +import CryptoKit +import Foundation +import LocalAuthentication +import Security +import TermindApplication + +public nonisolated enum SecureEnclaveKeyError: LocalizedError { + case unavailable + case accessControl(String) + case invalidSignature + + public var errorDescription: String? { + switch self { + case .unavailable: + String(localized: "Secure Enclave is not available on this device.") + case .accessControl(let detail): + String(localized: "Secure Enclave access could not be configured: \(detail)") + case .invalidSignature: + String(localized: "Secure Enclave returned an invalid P-256 signature.") + } + } +} + +public nonisolated enum SecureEnclaveKey { + public static var isAvailable: Bool { SecureEnclave.isAvailable } + + public static func generate(comment: String, requiresUserPresence: Bool) throws + -> SecureEnclaveKeyMaterial + { + guard isAvailable else { throw SecureEnclaveKeyError.unavailable } + + var flags: SecAccessControlCreateFlags = [.privateKeyUsage] + if requiresUserPresence { flags.insert(.userPresence) } + var accessError: Unmanaged? + guard + let accessControl = SecAccessControlCreateWithFlags( + nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &accessError + ) + else { + let detail = + accessError?.takeRetainedValue().localizedDescription + ?? String(localized: "Unknown Security framework error") + throw SecureEnclaveKeyError.accessControl(detail) + } + + let privateKey = try SecureEnclave.P256.Signing.PrivateKey( + compactRepresentable: false, + accessControl: accessControl + ) + let publicBlob = sshPublicBlob(for: privateKey.publicKey.x963Representation) + let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines) + let publicKey = + "ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())" + + (trimmedComment.isEmpty ? "" : " \(trimmedComment)") + return SecureEnclaveKeyMaterial( + credential: SecureEnclaveKeyCredential( + dataRepresentation: privateKey.dataRepresentation + ), + publicKey: publicKey, + fingerprint: fingerprint(of: publicBlob) + ) + } + + /// Returns the SSH ECDSA signature payload: two length-prefixed mpints (r, s). + /// libssh2 adds the outer algorithm and signature strings around this value. + public static func sign(_ data: Data, credential: SecureEnclaveKeyCredential) throws -> Data { + guard isAvailable else { throw SecureEnclaveKeyError.unavailable } + let context = LAContext() + context.localizedReason = String(localized: "Use your Secure Enclave SSH key") + let privateKey = try SecureEnclave.P256.Signing.PrivateKey( + dataRepresentation: credential.dataRepresentation, + authenticationContext: context + ) + let rawSignature = try privateKey.signature(for: data).rawRepresentation + guard rawSignature.count == 64 else { throw SecureEnclaveKeyError.invalidSignature } + return sshField(mpint(rawSignature.prefix(32))) + + sshField(mpint(rawSignature.suffix(32))) + } + + public static func publicBlob(from authorizedKey: String) -> Data? { + let parts = authorizedKey.split(whereSeparator: \Character.isWhitespace) + guard parts.count >= 2, parts[0] == "ecdsa-sha2-nistp256" else { return nil } + return Data(base64Encoded: String(parts[1])) + } + + private static func sshPublicBlob(for x963Representation: Data) -> Data { + sshField(Data("ecdsa-sha2-nistp256".utf8)) + + sshField(Data("nistp256".utf8)) + + sshField(x963Representation) + } + + private static func fingerprint(of blob: Data) -> String { + let digest = Data(SHA256.hash(data: blob)).base64EncodedString() + .replacingOccurrences(of: "=", with: "") + return "SHA256:\(digest)" + } + + private static func mpint(_ bytes: S) -> Data { + var value = Data(bytes).drop(while: { $0 == 0 }) + if value.isEmpty { return Data() } + if value.first.map({ $0 & 0x80 != 0 }) == true { + value.insert(0, at: value.startIndex) + } + return Data(value) + } + + private static func sshField(_ value: Data) -> Data { + var length = UInt32(value.count).bigEndian + var result = withUnsafeBytes(of: &length) { Data($0) } + result.append(value) + return result + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift new file mode 100644 index 00000000..dc943843 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Transfers/SFTPTransferCoordinator.swift @@ -0,0 +1,564 @@ +import Foundation +import TermindApplication +import TermindDomain + +import struct TermindDomain.Host + +public nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable { + public var token: UUID + public init(token: UUID) { self.token = token } +} + +public nonisolated enum SFTPTransferConnection: Sendable { + case saved(SSHConnectionConfiguration) + case borrowed(id: UUID, client: any SFTPClientProtocol) + + public var borrowedID: UUID? { + if case .borrowed(let id, _) = self { id } else { nil } + } +} + +public nonisolated struct SFTPRemoteCopySource: Sendable { + public var hostID: Host.ID + public var connection: SFTPTransferConnection + public var files: [RemoteFile] + public var homeDirectory: String? + + public init( + hostID: Host.ID, connection: SFTPTransferConnection, files: [RemoteFile], + homeDirectory: String? = nil + ) { + self.hostID = hostID + self.connection = connection + self.files = files + self.homeDirectory = homeDirectory + } + + public init(hostID: Host.ID, configuration: SSHConnectionConfiguration, files: [RemoteFile]) { + self.init(hostID: hostID, connection: .saved(configuration), files: files) + } +} + +public nonisolated struct SFTPRemoteManifestEntry: Sendable { + public var sourcePath: String + public var relativePath: String + public var kind: RemoteFile.Kind + public var byteCount: Int64 + + public var isDirectory: Bool { kind == .directory } +} + +public nonisolated enum TransferConflictPolicy: String, CaseIterable, Identifiable, Sendable { + case replace, skip, keepBoth + public var id: String { rawValue } +} + +public nonisolated struct SFTPTransferSummary: Equatable, Sendable { + public var activeCount: Int + public var failedCount: Int + public var aggregateProgress: Double? +} + +@Observable +public final class SFTPTransferCoordinator { + public typealias ClientFactory = @Sendable () -> any SFTPClientProtocol + + private struct Runtime { + var connection: SFTPTransferConnection + var localURL: URL + var remotePath: String + var overwrite: Bool + var scopedRoot: URL + var cancellation: TransferCancellationToken + var completion: ((SFTPTransfer.State) -> Void)? + } + + private struct RemoteCopyRuntime { + var sourceHostID: Host.ID + var sourceConnection: SFTPTransferConnection + var sourcePath: String + var destinationHostID: Host.ID + var destinationConnection: SFTPTransferConnection + var destinationPath: String + var overwrite: Bool + var cancellation: TransferCancellationToken + var completion: ((SFTPTransfer.State) -> Void)? + } + + private struct ClientLease { + let client: any SFTPClientProtocol + let isOwned: Bool + + func disconnect() async { + if isOwned { await client.disconnect() } + } + } + + private let store: HostLibraryModel + private let clientFactory: ClientFactory + private var runtimes: [SFTPTransfer.ID: Runtime] = [:] + private var remoteCopyRuntimes: [SFTPTransfer.ID: RemoteCopyRuntime] = [:] + private var activeTasks: [SFTPTransfer.ID: Task] = [:] + private var activePerHost: [Host.ID: Int] = [:] + private var activeBorrowedConnections: Set = [] + private var remoteCopySources: [UUID: (source: SFTPRemoteCopySource, createdAt: Date)] = [:] + + public private(set) var globalLimit: Int + public private(set) var perHostLimit: Int + + public init( + store: HostLibraryModel, + clientFactory: @escaping ClientFactory, + globalLimit: Int? = nil, perHostLimit: Int? = nil + ) { + self.store = store + self.clientFactory = clientFactory + let defaults = UserDefaults.standard + self.globalLimit = Self.normalizedLimit( + globalLimit ?? defaults.object(forKey: "sftp.transfers.globalLimit") as? Int + ?? 3 + ) + self.perHostLimit = Self.normalizedLimit( + perHostLimit ?? defaults.object(forKey: "sftp.transfers.perHostLimit") as? Int + ?? 2 + ) + } + + public func updateLimits(global: Int, perHost: Int) { + globalLimit = Self.normalizedLimit(global) + perHostLimit = min(Self.normalizedLimit(perHost), globalLimit) + schedule() + } + + private static func normalizedLimit(_ value: Int) -> Int { min(max(value, 1), 8) } + + public var sftpTransfers: [SFTPTransfer] { + store.sftpTransfers.sorted { $0.queuedAt < $1.queuedAt } + } + public var activeCount: Int { activeTasks.count } + public var summary: SFTPTransferSummary { + let active = sftpTransfers.filter { $0.state == .queued || $0.state == .running } + let failedCount = sftpTransfers.filter { + if case .failed = $0.state { true } else { false } + }.count + let measurable = active.filter { $0.byteCount > 0 } + let total = measurable.reduce(Int64(0)) { $0 + $1.byteCount } + let completed = measurable.reduce(Int64(0)) { + $0 + min($1.transferredBytes, $1.byteCount) + } + let aggregateProgress: Double? + if total > 0 { + aggregateProgress = min(1, Double(completed) / Double(total)) + } else { + aggregateProgress = nil + } + return SFTPTransferSummary( + activeCount: active.count, + failedCount: failedCount, + aggregateProgress: aggregateProgress + ) + } + + public func registerRemoteCopySource(_ source: SFTPRemoteCopySource) -> SFTPRemoteItemsPayload { + let expiration = Date().addingTimeInterval(-60 * 60) + remoteCopySources = remoteCopySources.filter { $0.value.createdAt > expiration } + let payload = SFTPRemoteItemsPayload(token: UUID()) + remoteCopySources[payload.token] = (source, Date()) + return payload + } + + public func remoteCopySource(for payload: SFTPRemoteItemsPayload) -> SFTPRemoteCopySource? { + remoteCopySources[payload.token]?.source + } + + public func remoteManifest(for payload: SFTPRemoteItemsPayload) async throws + -> (source: SFTPRemoteCopySource, entries: [SFTPRemoteManifestEntry]) + { + guard let source = remoteCopySource(for: payload) else { + throw SFTPError.operation("The copied SFTP items are no longer available.") + } + let borrowedID = source.connection.borrowedID + if let borrowedID { + try await reserveBorrowedConnection(borrowedID) + } + defer { + if let borrowedID { releaseBorrowedConnections([borrowedID]) } + } + let lease = try await clientLease(for: source.connection) + let client = lease.client + do { + var entries: [SFTPRemoteManifestEntry] = [] + func collect(_ file: RemoteFile, relativePath: String) async throws { + entries.append( + SFTPRemoteManifestEntry( + sourcePath: file.path, relativePath: relativePath, + kind: file.kind, byteCount: file.size + )) + guard file.isDirectory else { return } + for child in try await client.listDirectory(at: file.path) { + try await collect( + child, + relativePath: relativePath + "/" + child.name + ) + } + } + for file in source.files { + try await collect(file, relativePath: file.name) + } + await lease.disconnect() + return (source, entries) + } catch { + await lease.disconnect() + throw error + } + } + + public func enqueue( + direction: SFTPTransfer.Direction, batchID: UUID, + localURL: URL, remotePath: String, byteCount: Int64, + hostID: Host.ID, connection: SFTPTransferConnection, + scopedRoot: URL, overwrite: Bool, + homeDirectory: String? = nil, + completion: ((SFTPTransfer.State) -> Void)? = nil + ) { + let sftpTransfer = SFTPTransfer( + batchID: batchID, hostID: hostID, + sourceHostID: direction == .download ? hostID : nil, + destinationHostID: direction == .upload ? hostID : nil, + direction: direction, + localPath: localURL.path, remotePath: remotePath, + sourceHomeDirectory: direction == .download ? homeDirectory : nil, + destinationHomeDirectory: direction == .upload ? homeDirectory : nil, + byteCount: byteCount, queuedAt: Date() + ) + store.enqueueSFTPTransfer(sftpTransfer) + runtimes[sftpTransfer.id] = Runtime( + connection: connection, localURL: localURL, + remotePath: remotePath, overwrite: overwrite, + scopedRoot: scopedRoot, cancellation: TransferCancellationToken(), + completion: completion + ) + schedule() + } + + public func enqueue( + direction: SFTPTransfer.Direction, batchID: UUID, + localURL: URL, remotePath: String, byteCount: Int64, + hostID: Host.ID, configuration: SSHConnectionConfiguration, + scopedRoot: URL, overwrite: Bool, + homeDirectory: String? = nil, + completion: ((SFTPTransfer.State) -> Void)? = nil + ) { + enqueue( + direction: direction, batchID: batchID, localURL: localURL, + remotePath: remotePath, byteCount: byteCount, hostID: hostID, + connection: .saved(configuration), scopedRoot: scopedRoot, + overwrite: overwrite, homeDirectory: homeDirectory, completion: completion + ) + } + + public func enqueueRemoteCopy( + batchID: UUID, + sourcePath: String, sourceHostID: Host.ID, + sourceConnection: SFTPTransferConnection, + destinationPath: String, destinationHostID: Host.ID, + destinationConnection: SFTPTransferConnection, + byteCount: Int64, overwrite: Bool, + sourceHomeDirectory: String? = nil, + destinationHomeDirectory: String? = nil, + completion: ((SFTPTransfer.State) -> Void)? = nil + ) { + let sftpTransfer = SFTPTransfer( + batchID: batchID, hostID: destinationHostID, + sourceHostID: sourceHostID, destinationHostID: destinationHostID, + direction: .copy, + localPath: sourcePath, remotePath: destinationPath, + sourceHomeDirectory: sourceHomeDirectory, + destinationHomeDirectory: destinationHomeDirectory, + byteCount: byteCount, queuedAt: Date() + ) + store.enqueueSFTPTransfer(sftpTransfer) + remoteCopyRuntimes[sftpTransfer.id] = RemoteCopyRuntime( + sourceHostID: sourceHostID, + sourceConnection: sourceConnection, + sourcePath: sourcePath, + destinationHostID: destinationHostID, + destinationConnection: destinationConnection, + destinationPath: destinationPath, + overwrite: overwrite, + cancellation: TransferCancellationToken(), + completion: completion + ) + schedule() + } + + public func enqueueRemoteCopy( + batchID: UUID, + sourcePath: String, sourceHostID: Host.ID, + sourceConfiguration: SSHConnectionConfiguration, + destinationPath: String, destinationHostID: Host.ID, + destinationConfiguration: SSHConnectionConfiguration, + byteCount: Int64, overwrite: Bool, + completion: ((SFTPTransfer.State) -> Void)? = nil + ) { + enqueueRemoteCopy( + batchID: batchID, sourcePath: sourcePath, sourceHostID: sourceHostID, + sourceConnection: .saved(sourceConfiguration), + destinationPath: destinationPath, destinationHostID: destinationHostID, + destinationConnection: .saved(destinationConfiguration), + byteCount: byteCount, overwrite: overwrite, completion: completion + ) + } + + public func cancel(_ id: SFTPTransfer.ID) { + guard let sftpTransfer = store.sftpTransfer(id) else { return } + if sftpTransfer.state == .queued { + runtimes.removeValue(forKey: id) + remoteCopyRuntimes.removeValue(forKey: id) + store.finishSFTPTransfer(id, state: .cancelled) + } else { + if let runtime = runtimes[id] { + runtime.cancellation.cancel() + } else { + remoteCopyRuntimes[id]?.cancellation.cancel() + } + } + } + + public func cancelAll() { + let ids = store.sftpTransfers.compactMap { sftpTransfer -> SFTPTransfer.ID? in + switch sftpTransfer.state { + case .queued, .running: sftpTransfer.id + case .paused, .completed, .failed, .cancelled: nil + } + } + for id in ids { cancel(id) } + } + + public func cancelAll(for hostID: Host.ID) { + for transfer in store.sftpTransfers where transfer.involves(hostID) { + cancel(transfer.id) + } + } + + public func cancelTransfers(usingBorrowedConnection connectionID: UUID) async { + let ids = store.sftpTransfers.compactMap { transfer -> SFTPTransfer.ID? in + guard transfer.state == .queued || transfer.state == .running, + borrowedIDs(for: transfer).contains(connectionID) + else { return nil } + return transfer.id + } + for id in ids { cancel(id) } + while activeBorrowedConnections.contains(connectionID) { + try? await Task.sleep(for: .milliseconds(10)) + } + } + + public func clearFinished() { store.clearFinishedSFTPTransfers() } + + private func schedule() { + while activeTasks.count < globalLimit { + guard + let sftpTransfer = store.sftpTransfers.first(where: { sftpTransfer in + guard sftpTransfer.state == .queued, + runtimes[sftpTransfer.id] != nil || remoteCopyRuntimes[sftpTransfer.id] != nil + else { return false } + let borrowedIDs = borrowedIDs(for: sftpTransfer) + guard borrowedIDs.allSatisfy({ !activeBorrowedConnections.contains($0) }) else { + return false + } + return hostIDs(for: sftpTransfer).allSatisfy { + activePerHost[$0, default: 0] < perHostLimit + } + }) + else { return } + start(sftpTransfer) + } + } + + private func start(_ sftpTransfer: SFTPTransfer) { + if let runtime = remoteCopyRuntimes[sftpTransfer.id] { + startRemoteCopy(sftpTransfer, runtime: runtime) + return + } + guard let hostID = sftpTransfer.hostID, let runtime = runtimes[sftpTransfer.id] else { return } + store.setSFTPTransferState(.running, for: sftpTransfer.id) + activePerHost[hostID, default: 0] += 1 + let borrowedIDs = Array(runtime.connection.borrowedID.map { [$0] } ?? []) + activeBorrowedConnections.formUnion(borrowedIDs) + let task = Task { [weak self] in + guard let self else { return } + let scoped = runtime.scopedRoot.startAccessingSecurityScopedResource() + defer { if scoped { runtime.scopedRoot.stopAccessingSecurityScopedResource() } } + var lease: ClientLease? + let result: SFTPTransfer.State + do { + let acquired = try await clientLease(for: runtime.connection) + lease = acquired + let client = acquired.client + let progress: @Sendable (SFTPProgress) -> Void = { [weak self] value in + Task { @MainActor in + self?.store.updateSFTPTransfer( + sftpTransfer.id, completedBytes: value.completedBytes, + totalBytes: value.totalBytes, bytesPerSecond: value.bytesPerSecond + ) + } + } + switch sftpTransfer.direction { + case .upload: + try await client.uploadFile( + from: runtime.localURL, to: runtime.remotePath, + overwrite: runtime.overwrite, cancellation: runtime.cancellation, + progress: progress + ) + case .download: + try FileManager.default.createDirectory( + at: runtime.localURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try await client.downloadFile( + from: runtime.remotePath, to: runtime.localURL, + overwrite: runtime.overwrite, cancellation: runtime.cancellation, + progress: progress + ) + case .copy: + throw SFTPError.operation("Invalid copy transfer runtime.") + } + result = .completed + } catch SFTPError.cancelled { + result = .cancelled + } catch is CancellationError { + result = .cancelled + } catch { + result = .failed(error.localizedDescription) + } + await lease?.disconnect() + finish( + sftpTransfer.id, hostIDs: [hostID], borrowedIDs: borrowedIDs, + state: result + ) + } + activeTasks[sftpTransfer.id] = task + } + + private func startRemoteCopy(_ sftpTransfer: SFTPTransfer, runtime: RemoteCopyRuntime) { + let involvedHostIDs = Array(Set([runtime.sourceHostID, runtime.destinationHostID])) + store.setSFTPTransferState(.running, for: sftpTransfer.id) + for hostID in involvedHostIDs { activePerHost[hostID, default: 0] += 1 } + let borrowedIDs = [ + runtime.sourceConnection.borrowedID, + runtime.destinationConnection.borrowedID, + ].compactMap { $0 } + activeBorrowedConnections.formUnion(borrowedIDs) + let task = Task { [weak self] in + guard let self else { return } + var sourceLease: ClientLease? + var destinationLease: ClientLease? + let result: SFTPTransfer.State + do { + let acquiredSource = try await clientLease(for: runtime.sourceConnection) + sourceLease = acquiredSource + let acquiredDestination = try await clientLease( + for: runtime.destinationConnection + ) + destinationLease = acquiredDestination + let progress: @Sendable (SFTPProgress) -> Void = { [weak self] value in + Task { @MainActor in + self?.store.updateSFTPTransfer( + sftpTransfer.id, completedBytes: value.completedBytes, + totalBytes: value.totalBytes, + bytesPerSecond: value.bytesPerSecond + ) + } + } + try await acquiredSource.client.copyFile( + from: runtime.sourcePath, + to: runtime.destinationPath, + on: acquiredDestination.client, + byteCount: sftpTransfer.byteCount, + overwrite: runtime.overwrite, + cancellation: runtime.cancellation, + progress: progress + ) + result = .completed + } catch SFTPError.cancelled { + result = .cancelled + } catch is CancellationError { + result = .cancelled + } catch { + result = .failed(error.localizedDescription) + } + await sourceLease?.disconnect() + await destinationLease?.disconnect() + finish( + sftpTransfer.id, hostIDs: involvedHostIDs, + borrowedIDs: borrowedIDs, state: result + ) + } + activeTasks[sftpTransfer.id] = task + } + + private func hostIDs(for sftpTransfer: SFTPTransfer) -> [Host.ID] { + if let runtime = remoteCopyRuntimes[sftpTransfer.id] { + return Array(Set([runtime.sourceHostID, runtime.destinationHostID])) + } + return sftpTransfer.hostID.map { [$0] } ?? [] + } + + private func clientLease( + for connection: SFTPTransferConnection + ) async throws -> ClientLease { + switch connection { + case .saved(let configuration): + let client = clientFactory() + do { + try await client.connect(using: configuration, progress: nil) + } catch { + await client.disconnect() + throw error + } + return ClientLease(client: client, isOwned: true) + case .borrowed(_, let client): + return ClientLease(client: client, isOwned: false) + } + } + + private func borrowedIDs(for sftpTransfer: SFTPTransfer) -> [UUID] { + if let runtime = remoteCopyRuntimes[sftpTransfer.id] { + return [ + runtime.sourceConnection.borrowedID, + runtime.destinationConnection.borrowedID, + ].compactMap { $0 } + } + return runtimes[sftpTransfer.id]?.connection.borrowedID.map { [$0] } ?? [] + } + + private func reserveBorrowedConnection(_ id: UUID) async throws { + while activeBorrowedConnections.contains(id) { + try Task.checkCancellation() + try await Task.sleep(for: .milliseconds(10)) + } + activeBorrowedConnections.insert(id) + } + + private func releaseBorrowedConnections(_ ids: [UUID]) { + activeBorrowedConnections.subtract(ids) + schedule() + } + + private func finish( + _ id: SFTPTransfer.ID, hostIDs: [Host.ID], borrowedIDs: [UUID] = [], + state: SFTPTransfer.State + ) { + activeTasks.removeValue(forKey: id) + for hostID in hostIDs { + activePerHost[hostID] = max(0, activePerHost[hostID, default: 1] - 1) + } + releaseBorrowedConnections(borrowedIDs) + let completion = + runtimes.removeValue(forKey: id)?.completion + ?? remoteCopyRuntimes.removeValue(forKey: id)?.completion + store.finishSFTPTransfer(id, state: state) + completion?(state) + } +} diff --git a/Memory/ARCHITECTURE.md b/Memory/ARCHITECTURE.md index bd6875b1..a297f5d5 100644 --- a/Memory/ARCHITECTURE.md +++ b/Memory/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Termind architecture -Termind uses one Xcode project with two app targets (`TFM` and `TFI`), four +Termind uses one Xcode project with two app targets (`TFM` and `TFI`), six static frameworks, three test targets, and the macOS-only MCP bridge. AgentKit remains a versioned remote SPM dependency developed in the sibling repository. @@ -8,13 +8,20 @@ remains a versioned remote SPM dependency developed in the sibling repository. - `Frameworks/TermindDomain` owns platform-independent values and rules. It may use AgentKit values, Foundation, and CryptoKit, never UI or concrete I/O. -- `TermindApplication` owns the extracted repository ports and reconnect policy; - it depends on Domain. -- `TermindInfrastructure` owns the memory repository and metrics parsers; it - depends on Application and Domain. +- `TermindApplication` owns repository ports, Vault/AI feature models, secret interfaces, + SSH configuration/preparation, SSH import preview, the outbox runner, and reconnect policy; it depends on Domain and AgentKit. +- `TermindInfrastructure` owns the memory repository, metrics parsers and StoreKit access. + It depends on Application and Domain. +- `TermindRemoteInfrastructure` owns SSH/SFTP, key material, Keychain and remote sampling. + It also owns scoped SSH import reads, forwarding and transfer execution. + Its C libraries are linked only through this target. Keeping it outside the portable + FrameworkTests dependency graph avoids Xcode promoting OpenSSL to an empty dynamic + package wrapper during multi-host testing. It depends on Infrastructure, Application and Domain. +- `TermindPresentation` owns reusable SwiftUI content. It depends on Application and Domain, + never on app targets, platform UI frameworks, transports or persistence. - `TermindPersistence` owns every SwiftData model, frozen historical schema, migration plan, and the shared container factory. It depends on Domain, - AgentKit, and SwiftData, never an application target. + AgentKit, Application repository interfaces, and SwiftData, never an application target. - `TFM` retains desktop feature models, adapters, views and application/window orchestration. `TFI` owns its native mobile entry point and scene navigation. Platform code can consume frameworks; frameworks cannot consume either app. @@ -34,10 +41,10 @@ fixtures test this boundary independently of newly generated stores. `TermindPersistence.makeContainer` fixes the complete schema and migration plan. Each app process owns one container shared by its scenes. The desktop keeps the -`Termind` store configuration and its existing local location; the mobile -scaffold uses an in-memory container with exactly the same schema. Store failures -never silently recreate a database. CloudKit sync and Keychain access remain in -the desktop adapters until separately ported; model sharing does not enable sync. +`Termind` store configuration and its existing local location; mobile uses an +Application Support store with the same schema and an in-memory container for tests. Store failures +never silently recreate a database. Keychain access is shared and explicitly configured +by each app. Mobile CloudKit coordinators are still pending; model sharing does not enable sync. Shared resources are copied into each app bundle. Localized framework strings resolve against the host bundle; platform icons and metadata remain app-owned. @@ -544,3 +551,42 @@ markers prevent delayed Keychain deliveries from reviving a cleared value. Explicit credentials stay out of CloudKit; custom MCP headers and custom terminal configuration are ordinary user-authored settings. The private-account sync adapter is the only additional trusted source of skill-library snapshots. + +## Mobile presentation and workspace activities + +The iPhone has four top-level destinations in order: Welcome (including Vault and +Workspaces), Metrics, Assistant, and Settings. iPad retains a sidebar. Each mobile Workspace +owns one SSH or SFTP session; composition is never enabled by a purchase. Mobile +Assistant is independent and has no workspace sidebar or session-attached sheet. + +Shared form content is mounted in the desktop work column and in mobile SwiftUI +sheets. Short mobile forms offer medium/large detents; complex forms use large. +Dirty drafts prevent interactive dismissal and survive size changes. + +The iOS widget extension uses one ActivityKit activity for all workspaces, not one +notification or activity per workspace. Its bounded state contains counts and up to +six host system icons, resolved through the same distribution catalog as Vault, +with the focused session first and a list link for overflow. An unrecognized +system uses the server icon; SSH shell-integration updates refresh the artwork. +Each icon opens `termind://workspace/`; the compact island opens the focused +session, while `termind://workspaces` opens the list. Links are validated and carry +no commands or credentials. A stale activity displays an instruction to check the +connections instead of asserting connectivity. Closing the last workspace ends it. +Activity starts, updates and ends are serialized, and starts require the foreground. + +Live Activities do not keep SSH alive. The app requests finite background execution, +stops sampling and Assistant runs, and closes transports when that time expires. +Opening a session link restores its page and reconnects if necessary using normal +credential and trust gates; commands and tool actions are never replayed. Restoration +persists only descriptor IDs, host references, titles and session kinds. + +Mobile transfer queues use the same coordinator as desktop and own dedicated connection +configurations. Closing a browser does not cancel those transfers. Deleting a host or +expiration of background execution cancels its work; no transfer is silently replayed. +Downloads are staged in a unique app-temporary directory and exported through the system +share sheet. Remote names are validated before becoming local path components. + +Mobile host deletion records the existing deletion saga and starts the shared outbox +runner. Restored pending deletions are hidden before workspace restoration. The runner's +start/stop generation prevents a cancelled background loop from clearing a replacement +foreground loop. New port forwarding rules never start merely because they were saved. diff --git a/Memory/MOBILE_ROLLOUT.md b/Memory/MOBILE_ROLLOUT.md new file mode 100644 index 00000000..f37c6a8f --- /dev/null +++ b/Memory/MOBILE_ROLLOUT.md @@ -0,0 +1,71 @@ +# Mobile rollout status + +## Implemented + +- Shared Vault and AI configuration models and persistence, SSH/SFTP transports, + Keychain adapters, StoreKit access, metrics sampling, and a SwiftUI presentation target. +- Four iPhone navigation destinations: Welcome/Vault/Workspaces, Metrics, Assistant, Settings. + iPad retains the sidebar. No mobile workspace composition or workspace-attached Assistant. +- Mobile host, identity, group, snippet and provider forms use sheets; desktop host + connection fields reuse the same SwiftUI content. +- Independent SSH/SFTP workspace descriptors, reconnect entry points, terminal rendering, + system keyboard and a SwiftUI accessory bar using Ghostty's sticky modifiers. +- One ActivityKit activity with up to six host system icons and an overflow list link. + Each icon deep-links to its workspace and reconnects through credential/trust checks. + Compact/minimal presentations open the focused workspace. The distribution artwork + is resolved through the same catalog used by the host list and refreshed by shell integration. +- Workspace restoration contains metadata only. Live Activities do not grant execution + time; the app uses finite background execution and disconnects at expiration. +- SFTP browsing, upload/download, rename, permission editing, directory creation and deletion; + an app-wide shared transfer queue with cancellation and current-launch history; metrics cards; independent + streaming chat with persisted history; provider configuration and Pro purchase presentation. + +- Key generation (including supported Secure Enclave devices), encrypted key import, + metadata/certificate editing, and key/identity deletion. File parsing and generation + run off MainActor, and secrets stay in the shared secret store. +- SSH folder/known_hosts import uses the shared reader and preview model. Missing usernames + require an explicit edit on iOS; no local process username is substituted. References + outside granted folders still require explicit file selection. +- Port forwarding management uses the shared runtime and transactional rule updates. +- Host deletion uses the shared outbox runner, including interrupted-job recovery on startup. +- iOS Keychain sharing entitlement and localized Face ID usage description are configured. + +## Remaining from the full plan + +The full mobile port is not complete. Do not describe the current implementation as feature parity. + +- Port complete shared Assistant tool registration, approval/user-input/secret presentation, + SSH/SFTP agent tools, model discovery/roles, Skills, MCP settings and speech input. + The current mobile runtime intentionally registers no tools. +- Wire mobile CloudKit Vault/Settings coordinators and CloudKit signing capabilities; + current mobile data is local, despite using the shared schema and journals. +- Complete terminal preferences, history/search UI and broader desktop SwiftUI view reuse. +- Test live SSH/SFTP, ActivityKit rendering and taps, external keyboards, background/lock + behavior, CloudKit and StoreKit on signed physical devices. Widget icon routing and + bounded payload behavior have automated coverage; real-device behavior is unverified. + +## Verification so far + +- iOS simulator build and tests pass: 15 mobile tests and 20 shared framework tests. +- macOS focused tests pass: 42 tests covering architecture, screenshot fixtures, + command history and application language. +- Full macOS run executed 1,122 tests. Agent deadline/approval failures passed on a + focused rerun. DataProtectionKeychainTests still returned OSStatus -25308 and + MetricsInteractionTests still failed native popover focus/dismissal expectations. + These failures remain unresolved; do not report the full suite as passing. +- Simplified Chinese localization checks pass. New UI text has translations. +- iPad sidebar and sheet presentation inspected in Simulator. Inspection caught missing + persistent field labels; the shared form now uses LabeledContent for those fields. + +## Continuation verification (2026-09-11) + +- 51 macOS tests passed for SSH import, forwarding, repository/outbox behavior and architecture. +- 49 macOS SFTP feature/session/history tests passed after sharing the transfer coordinator + and Unix permission controls. The permission-grid refactor in REFACTOR.md is verified. +- Mobile tests cover encrypted key generation/import, rejection of a wrong passphrase, + metadata edits preserving credential references, explicit import usernames, transactional + forwarding changes, and completion of durable host-deletion cleanup. + +- The continuation's full macOS run again executed 1,122 tests and reported 14 issues + in the previously failing deadline/approval, Keychain (-25308), and native popover suites. + The full suite is still not green; the focused feature suites above passed. diff --git a/Memory/REFACTOR.md b/Memory/REFACTOR.md index 36c2d6f2..3031c9ef 100644 --- a/Memory/REFACTOR.md +++ b/Memory/REFACTOR.md @@ -1051,3 +1051,10 @@ state and restoration disabled. Report native titlebar height and backing scale **Verification:** Full pinned macOS test run passed, including the new architecture guard for a single root host. Native capture confirms a 52-point toolbar and visible terminal and chat content. Strict formatting and Chinese localization checks passed. + +## 2026-09-11 — Shared SFTP permission controls + +- Evidence: `rg 'accessRows|permissionToggle|SFTPPermissionDraft' TFM/Features/SFTP` confirmed the permission grid and its row metadata were desktop-owned while mobile needed the same bit editing behavior. +- Action: moved the value-only permission draft into Domain and extracted a SwiftUI permission grid into Presentation, used by both editors. Removed the desktop-only row table and toggle builder. +- Estimated reduction: approximately 45 desktop view lines, with a single shared permission-control implementation replacing a second mobile implementation. +- Status: implemented; regression verification is recorded in `MOBILE_ROLLOUT.md`. diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 8ca03153..5748fb83 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -1,24 +1,200 @@ import Foundation import Observation import SwiftData +import SwiftUI import TermindApplication +import TermindDomain import TermindInfrastructure import TermindPersistence +import TermindRemoteInfrastructure -/// App-owned services are shared by every scene; selection belongs to each scene. @MainActor @Observable final class MobileAppModel { let container: ModelContainer let hosts: any HostRepository + let library: HostLibraryModel + let credentials: CredentialLibraryModel + let snippets: SnippetLibraryModel + let knownHosts: KnownHostsLibraryModel + let forwardingLibrary: ForwardingLibraryModel + let forwardings: PortForwardingController + let transfers: SFTPTransferCoordinator + private(set) var workspaces: [MobileWorkspace] = [] + var error: String? + let ai: AIConfigurationModel + let assistant: MobileAssistantModel + let metrics: MobileMetricsModel + let pro: ProAccessController + let liveActivities: MobileLiveActivityController + private let defaults: UserDefaults? + private let background = MobileBackgroundController() + let outbox: OutboxRunner + private var scenePhases: [UUID: ScenePhase] = [:] - init(hosts: any HostRepository = InMemoryHostRepository()) throws { - container = try TermindPersistence.makeContainer(inMemory: true) - self.hosts = hosts + init(hosts: (any HostRepository)? = nil, inMemory: Bool = false, defaults: UserDefaults? = .standard) throws { + let memoryOnly = inMemory || hosts != nil + let storeURL = URL.applicationSupportDirectory.appending(path: "Termind.store") + if !memoryOnly { + try FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true) + } + container = try TermindPersistence.makeContainer(inMemory: memoryOnly, url: memoryOnly ? nil : storeURL) + let vaultContext = ModelContext(container) + self.hosts = hosts ?? SwiftDataHostRepository(context: vaultContext) + outbox = OutboxRunner(store: SwiftDataOutboxStore(context: ModelContext(container))) + self.defaults = inMemory || hosts != nil ? nil : defaults + liveActivities = MobileLiveActivityController(enabled: !inMemory && hosts == nil) + metrics = MobileMetricsModel(defaults: inMemory || hosts != nil ? nil : defaults) + pro = inMemory || hosts != nil ? .unrestrictedForTesting() : .live() + let persistence = HostPersistence(context: vaultContext) + _ = try persistence.load() + let secrets: any SecretStoring = + inMemory || hosts != nil + ? InMemorySecretStore() + : KeychainSecretStore( + accessGroup: Bundle.main.object(forInfoDictionaryKey: "TermindKeychainAccessGroup") as? String + ?? "F6SUNDBBN9.com.akinokaede.termind") + let aiPersistence = AIPersistence(context: ModelContext(container)) + _ = try aiPersistence.load() + ai = AIConfigurationModel( + persistence: aiPersistence, secrets: secrets, + aiSecrets: AISecretStore(context: ModelContext(container), keychain: secrets)) + assistant = MobileAssistantModel( + configuration: ai, repository: SwiftDataAgentRunRepository(context: ModelContext(container))) + library = HostLibraryModel(persistence: persistence, secrets: secrets) + credentials = CredentialLibraryModel(library: library) + snippets = SnippetLibraryModel(library: library) + knownHosts = KnownHostsLibraryModel(library: library) + forwardingLibrary = ForwardingLibraryModel(library: library) + forwardings = PortForwardingController(hosts: library) + transfers = SFTPTransferCoordinator(store: library, clientFactory: { SFTPClient() }) + library.willDeletePortForwardings = { [weak forwardings] ids in forwardings?.stop(ids) } + let visibleHostIDs = Set(try self.hosts.snapshot().value.hosts.map(\.id)) + for host in library.hosts where !visibleHostIDs.contains(host.id) { library.projectHostDeletion(host.id) } + outbox.register( + RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.closeWorkspaces) { [weak self] payload in + guard let self else { return } + for workspace in workspaces.filter({ $0.descriptor.hostID == payload.hostID }) { close(workspace.id) } + library.projectHostDeletion(payload.hostID) + }) + outbox.register( + RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.cancelTransfers) { [weak self] payload in + guard let self else { return } + transfers.cancelAll(for: payload.hostID) + }) + outbox.register( + RuntimeHostDeletionHandler(handlerID: HostDeletionOutbox.stopForwardings) { [weak forwardings] payload in + forwardings?.stop(payload.forwardingRuleIDs) + }) + outbox.register( + RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.clearReferences, repository: self.hosts)) + outbox.register(RepositoryHostDeletionHandler(handlerID: HostDeletionOutbox.finalize, repository: self.hosts)) + if let data = self.defaults?.data(forKey: "mobile.workspaces.v1") { + let descriptors = try JSONDecoder().decode([MobileWorkspaceDescriptor].self, from: data) + workspaces = descriptors.filter { library.host($0.hostID) != nil }.map { + MobileWorkspace(descriptor: $0, hosts: library, transfers: transfers) + } + for workspace in workspaces { + workspace.didChangeState = { [weak self] in + guard let self else { return } + liveActivities.update(workspaces) + } + } + } + } + + func makeSSHImportModel() -> SSHImportModel { + SSHImportModel(library: library, reader: SSHImportReader(username: "")) + } + + func open(hostID: UUID, kind: MobileWorkspaceDescriptor.Kind) -> UUID? { + guard let host = library.host(hostID) else { return nil } + let descriptor = MobileWorkspaceDescriptor(hostID: hostID, title: host.name, kind: kind) + let workspace = MobileWorkspace(descriptor: descriptor, hosts: library, transfers: transfers) + workspaces.append(workspace) + persistWorkspaces() + workspace.didChangeState = { [weak self] in + guard let self else { return } + liveActivities.update(workspaces) + } + liveActivities.start(workspaces) + workspace.connect() + return workspace.id + } + + func close(_ id: UUID) { + guard let workspace = workspaces.first(where: { $0.id == id }) else { return } + workspace.disconnect() + workspaces.removeAll { $0.id == id } + liveActivities.update(workspaces) + persistWorkspaces() + } + + func deleteHost(_ id: UUID) { + do { + for workspace in workspaces.filter({ $0.descriptor.hostID == id }) { close(workspace.id) } + transfers.cancelAll(for: id) + forwardings.stop(library.portForwardings(for: id).map(\.id)) + try hosts.requestHostDeletion(id) + library.projectHostDeletion(id) + outbox.start() + } catch { self.error = error.localizedDescription } + } + + func sceneChanged(_ id: UUID, phase: ScenePhase) { + let wasBackground = !scenePhases.isEmpty && scenePhases.values.allSatisfy { $0 == .background } + scenePhases[id] = phase + let isBackground = scenePhases.values.allSatisfy { $0 == .background } + if isBackground && !wasBackground { + metrics.pause() + outbox.stop() + assistant.stop() + background.begin { [weak self] in + guard let self else { return } + for workspace in workspaces { workspace.disconnect() } + forwardings.stopAll() + transfers.cancelAll() + } + liveActivities.update(workspaces) + } else if phase == .active { + background.finish() + outbox.start() + liveActivities.update(workspaces) + metrics.start(hosts: library) + } + } + + func removeScene(_ id: UUID) { scenePhases[id] = nil } + + private func persistWorkspaces() { + do { + defaults?.set(try JSONEncoder().encode(workspaces.map(\.descriptor)), forKey: "mobile.workspaces.v1") + } catch { self.error = error.localizedDescription } } } nonisolated enum MobileDestination: String, CaseIterable, Identifiable { - case hosts, assistant, settings + case welcome, hosts, assistant, workspaces, metrics, settings var id: Self { self } + var title: String { + switch self { + case .welcome: String(localized: "Welcome") + case .hosts: String(localized: "Vault") + case .assistant: String(localized: "Assistant") + case .workspaces: String(localized: "Workspaces") + case .metrics: String(localized: "Metrics") + case .settings: String(localized: "Settings") + } + } + var symbol: String { + switch self { + case .welcome: "house" + case .hosts: "server.rack" + case .assistant: "sparkles" + case .workspaces: "terminal" + case .metrics: "gauge.with.dots.needle.33percent" + case .settings: "gearshape" + } + } } diff --git a/TFI/App/MobileAssistantModel.swift b/TFI/App/MobileAssistantModel.swift new file mode 100644 index 00000000..89e3d99d --- /dev/null +++ b/TFI/App/MobileAssistantModel.swift @@ -0,0 +1,98 @@ +import AgentKit +import Foundation +import Observation +import TermindApplication + +/// Independent mobile Assistant; no terminal surface or workspace owns this model. +@MainActor +@Observable +final class MobileAssistantModel { + private(set) var conversations: [StoredAgentConversation] = [] + private(set) var messages: [AgentTranscriptMessage] = [] + private(set) var isRunning = false + var selectedID = UUID() + var draft = "" + var error: String? + private var runtime: AgentRuntime? + private var task: Task? + private let repository: any AgentRunPersisting + private let configuration: AIConfigurationModel + + init(configuration: AIConfigurationModel, repository: any AgentRunPersisting) { + self.configuration = configuration + self.repository = repository + } + + func load() async { + do { conversations = try await repository.loadConversations() } catch { + self.error = error.localizedDescription + } + } + + func select(_ conversation: StoredAgentConversation) { + guard !isRunning else { return } + selectedID = conversation.id + messages = conversation.modelTranscript ?? conversation.messages + } + + func newConversation() { + guard !isRunning else { return } + selectedID = UUID() + messages = [] + } + + func send() { + guard !isRunning, !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } + guard let resolved = configuration.resolve(conversation: configuration.lastSelectedModel), + let secret = configuration.secret(for: resolved.provider.effectiveCredentialRef), !secret.isEmpty + else { + error = String(localized: "Configure a model and its API key in Settings before sending a message.") + return + } + let prompt = draft + let prior = messages + let message = AgentTranscriptMessage(role: .user, text: prompt) + messages.append(message) + draft = "" + error = nil + isRunning = true + let client = AgentProviderClient(provider: resolved.provider, model: resolved.model, secret: secret) + // Until a tool is registered it is absent from the model's capabilities, not silently skipped. + let approval = AgentApprovalBroker( + reviewer: nil, manualApproval: { _ in .deny("No approval presenter is available.") }) + let runtime = AgentRuntime(model: client, tools: [], approval: approval, repository: repository) + self.runtime = runtime + let request = AgentRunRequest( + conversationID: selectedID, promptID: message.id, prompt: prompt, + permissionMode: .askForApproval, priorMessages: prior) + task = Task { [weak self] in + let events = await runtime.start(request) + for await event in events { + guard let self else { + await runtime.cancel() + return + } + switch event { + case .messageStarted(let value), .messageFinished(let value), .steeringDelivered(let value): + if let index = messages.firstIndex(where: { $0.id == value.id }) { + messages[index] = value + } else { + messages.append(value) + } + case .messageDelta(let id, let text): + if let index = messages.firstIndex(where: { $0.id == id }) { messages[index].text += text } + case .failed(let message): error = message + default: break + } + } + self?.isRunning = false + self?.runtime = nil + await self?.load() + } + } + + func stop() { + guard let runtime else { return } + Task { await runtime.cancel() } + } +} diff --git a/TFI/App/MobileKeyEditorModel.swift b/TFI/App/MobileKeyEditorModel.swift new file mode 100644 index 00000000..7f6c4aa7 --- /dev/null +++ b/TFI/App/MobileKeyEditorModel.swift @@ -0,0 +1,118 @@ +import Foundation +import Observation +import TermindApplication +import TermindDomain +import TermindRemoteInfrastructure + +@MainActor +@Observable +final class MobileKeyEditorModel: Identifiable { + enum Mode { case generate, importKey, edit } + enum Algorithm: String, CaseIterable, Identifiable { + case ed25519 = "Ed25519" + case ecdsa = "ECDSA P-256" + case rsa = "RSA 4096" + var id: Self { self } + var generation: SSHKeyMaterial.Generation { + switch self { + case .ed25519: .ed25519 + case .ecdsa: .ecdsa(bits: 256) + case .rsa: .rsa(bits: 4096) + } + } + } + let id = UUID() + let mode: Mode + private let original: SSHKeyRef? + private let credentials: CredentialLibraryModel + var label: String + var storage: KeyStorage + var algorithm: Algorithm = .ed25519 + var privateKey = "" + var passphrase = "" + var certificate: String + var requiresUserPresence = false + + init(mode: Mode, credentials: CredentialLibraryModel, key: SSHKeyRef? = nil) { + self.mode = mode + self.credentials = credentials + original = key + label = key?.label ?? "" + storage = key?.storage ?? .thisDevice + certificate = key?.certificate ?? "" + } + + var isDirty: Bool { + label != (original?.label ?? "") || storage != (original?.storage ?? .thisDevice) + || certificate != (original?.certificate ?? "") || !privateKey.isEmpty || !passphrase.isEmpty + || algorithm != .ed25519 || requiresUserPresence + } + var canSave: Bool { + !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (mode != .importKey || !privateKey.isEmpty) + } + var publicKey: String? { original?.publicKey } + var fingerprint: String? { original?.fingerprint } + var isEnclave: Bool { original?.storage == .secureEnclave } + var canUseEnclave: Bool { SecureEnclaveKey.isAvailable } + + func loadFile(_ url: URL) async throws { + let text = try await Task.detached { + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else { + throw CocoaError(.fileReadTooLarge) + } + return try String(contentsOf: url, encoding: .utf8) + }.value + try Task.checkCancellation() + privateKey = text + if label.isEmpty { label = url.deletingPathExtension().lastPathComponent } + } + + func save() async throws { + guard canSave else { throw CocoaError(.coderInvalidValue) } + if var key = original { + key.label = label.trimmingCharacters(in: .whitespacesAndNewlines) + key.storage = storage + key.certificate = certificate + try credentials.upsert(key) + return + } + let comment = "termind" + let key: SSHKeyRef + let secret: StoredSSHKeyCredential + if storage == .secureEnclave { + let presence = requiresUserPresence + let material = try await Task.detached(priority: .userInitiated) { + try SecureEnclaveKey.generate(comment: comment, requiresUserPresence: presence) + }.value + try Task.checkCancellation() + key = SSHKeyRef( + label: label, algorithm: .ecdsa256, publicKey: material.publicKey, + fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", storage: .secureEnclave, + keySize: 256, requiresUserPresence: presence, createdAt: .now) + secret = .secureEnclave(material.credential) + } else { + let input = privateKey + let passphrase = passphrase.isEmpty ? nil : passphrase + let generation = algorithm.generation + let generates = mode == .generate + let material = try await Task.detached(priority: .userInitiated) { + if generates { + return try SSHKeyMaterial.generate(generation, comment: comment, passphrase: passphrase) + } + return try SSHKeyMaterial.inspect(privateKey: input, passphrase: passphrase, comment: comment) + }.value + try Task.checkCancellation() + key = SSHKeyRef( + label: label, algorithm: material.algorithm, publicKey: material.publicKey, + certificate: certificate, fingerprint: material.fingerprint, credentialRef: "termind.key.\(UUID())", + storage: storage, keySize: material.keySize, hasPassphrase: passphrase != nil, createdAt: .now) + secret = .software(SSHPrivateKeySecret(privateKey: material.privateKey)) + } + try credentials.upsert(key, credential: secret) + privateKey = "" + passphrase = "" + } +} diff --git a/TFI/App/MobileMetricsModel.swift b/TFI/App/MobileMetricsModel.swift new file mode 100644 index 00000000..4099d2e3 --- /dev/null +++ b/TFI/App/MobileMetricsModel.swift @@ -0,0 +1,75 @@ +import Foundation +import Observation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure + +@MainActor +@Observable +final class MobileMetricsModel { + private(set) var points: [UUID: MetricsPoint] = [:] + private(set) var errors: [UUID: String] = [:] + private(set) var selections: [MetricsSelection] + private let defaults: UserDefaults? + private var tasks: [UUID: Task] = [:] + + init(defaults: UserDefaults?) { + self.defaults = defaults + selections = + defaults?.data(forKey: "mobile.metrics.v1").flatMap { + try? JSONDecoder().decode([MetricsSelection].self, from: $0) + } ?? [] + } + + func add(_ id: UUID, pro: Bool) -> Bool { + if selections.contains(where: { $0.id == id }) { return true } + guard pro || selections.count < 3 else { return false } + selections.append(MetricsSelection(id: id)) + persist() + return true + } + + func remove(_ id: UUID) { + tasks.removeValue(forKey: id)?.cancel() + selections.removeAll { $0.id == id } + points[id] = nil + errors[id] = nil + persist() + } + + func pause() { + for task in tasks.values { task.cancel() } + tasks.removeAll() + } + + func start(hosts: any RemoteSessionHostStore) { + for selection in selections where selection.enabled && tasks[selection.id] == nil { + guard let host = hosts.host(selection.id) else { continue } + tasks[selection.id] = Task { [weak self] in + let sampler = SSHMetricsSampler(session: SSHExecSession()) + do { + let config = try await hosts.prepareSSHConnection(for: host, submissions: [:]).resolve() + try await sampler.connect(config) + var previous: MetricsSample? + while !Task.isCancelled { + let sample = try await sampler.sample() + try Task.checkCancellation() + self?.points[host.id] = MetricsCalculation.point( + previous: previous, current: sample, selection: selection, date: .now) + self?.errors[host.id] = nil + previous = sample + try await Task.sleep(for: .seconds(3)) + } + } catch { + if !Task.isCancelled { self?.errors[host.id] = error.localizedDescription } + } + await sampler.disconnect() + } + } + } + + private func persist() { + if let data = try? JSONEncoder().encode(selections) { defaults?.set(data, forKey: "mobile.metrics.v1") } + } +} diff --git a/TFI/App/MobileWorkspace.swift b/TFI/App/MobileWorkspace.swift new file mode 100644 index 00000000..a1e569de --- /dev/null +++ b/TFI/App/MobileWorkspace.swift @@ -0,0 +1,344 @@ +import Foundation +import GhosttyTerminal +import Observation +import TermindApplication +import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure + +import struct TermindDomain.Host + +@MainActor +@Observable +final class MobileWorkspace: Identifiable { + let descriptor: MobileWorkspaceDescriptor + var id: UUID { descriptor.id } + var detectedDistribution: DetectedDistribution? { hosts.host(descriptor.hostID)?.detectedDistribution } + var didChangeState: (() -> Void)? + private(set) var status = String(localized: "Disconnected") { didSet { didChangeState?() } } + private(set) var connected = false { didSet { didChangeState?() } } + private(set) var busy = false + var error: String? + var pendingHostKey: SSHHostKeyObservation? + var authenticationRequired = false + var username = "" + var password = "" + var passphrase = "" + var authenticationHostName: String { + hosts.host(authenticationHostID ?? descriptor.hostID)?.name ?? descriptor.title + } + private var authenticationHostID: UUID? + private var submissions: [UUID: SSHAuthenticationSubmission] = [:] + var path = "/" + private(set) var files: [RemoteFile] = [] + let transfers: SFTPTransferCoordinator? + private var transferConfiguration: SSHConnectionConfiguration? + private var directoryRequest = UUID() + private let surfaceOwner = MobileTerminalSurface() + let terminal: TerminalViewState + private let backend: InMemoryTerminalSession + private var ssh: SSHSessionActor? + private var sftp: (any SFTPClientProtocol)? + private var attempt: Task? + private var generation = UUID() + private let hosts: any RemoteSessionHostStore + private var viewport = InMemoryTerminalViewport(columns: 80, rows: 24) + + init( + descriptor: MobileWorkspaceDescriptor, hosts: any RemoteSessionHostStore, + transfers: SFTPTransferCoordinator? = nil + ) { + self.transfers = transfers + self.descriptor = descriptor + self.hosts = hosts + let relay = MobileTerminalRelay() + backend = InMemoryTerminalSession( + write: { data in Task { @MainActor in await relay.owner?.ssh?.write(data) } }, + resize: { size in Task { @MainActor in await relay.owner?.resize(size) } }, + suppressesPixelOnlyResizes: true + ) + terminal = TerminalViewState(configSource: .none) + terminal.configuration = TerminalSurfaceOptions(backend: .inMemory(backend), resizeThrottleMilliseconds: 35) + let owner = surfaceOwner + terminal.makePlatformView = { owner.view } + terminal.inputAccessoryItems = [] + relay.owner = self + } + + func connect() { + guard !busy, !connected else { return } + let token = UUID() + generation = token + busy = true + error = nil + status = String(localized: "Connecting") + attempt = Task { [weak self] in + guard let self else { return } + defer { if generation == token { busy = false } } + do { + guard let host = hosts.host(descriptor.hostID) else { throw CocoaError(.fileNoSuchFile) } + // Validate each hop in order so credentials and trust prompts name the actual endpoint. + for endpoint in hosts.route(for: host) { + let preparation = try hosts.prepareSSHConnection(for: endpoint, submissions: submissions) + let discovery = try await preparation.resolve(discoveryOnly: true) + let observation = try await SSHSessionActor.discoverHostKey( + hostID: endpoint.id, host: endpoint.host, port: endpoint.port, + proxy: discovery.proxy, jumps: discovery.jumps) + guard generation == token, !Task.isCancelled else { return } + let trusted = hosts.knownHosts(for: endpoint) + if !trusted.contains(where: { $0.identity == observation.identity }) { + if trusted.contains(where: { $0.algorithm == observation.algorithm }) { + hosts.markKnownHostMismatched(hostname: endpoint.host, port: endpoint.port) + throw TermindSSHError.hostKeyChanged( + expected: trusted.map(\.fingerprint), received: observation.fingerprint) + } + pendingHostKey = observation + status = String(localized: "Waiting for approval") + return + } + } + let preparation = try hosts.prepareSSHConnection(for: host, submissions: submissions) + let config = try await preparation.resolve() + guard generation == token, !Task.isCancelled else { return } + password = "" + passphrase = "" + if descriptor.kind == .ssh { + let backend = backend + let session = SSHSessionActor( + output: { backend.receive($0) }, + exit: { [weak self] result in + Task { @MainActor in + guard let self, self.generation == token else { return } + self.generation = UUID() + self.attempt?.cancel() + self.busy = false + self.connected = false + self.status = String(localized: "Disconnected") + if case .failure(let failure) = result { self.error = failure.localizedDescription } + } + }, + shellIntegration: { [weak self] event in + Task { @MainActor in + guard let self, self.generation == token else { return } + switch event { + case .available(_, let systemInfo, _): + self.hosts.recordRemoteSystemInfo(systemInfo, for: self.descriptor.hostID) + case .systemInfo(let systemInfo): + self.hosts.recordRemoteSystemInfo(systemInfo, for: self.descriptor.hostID) + default: return + } + self.didChangeState?() + } + }) + ssh = session + try await session.connect( + hostID: config.hostID, host: config.host, port: config.port, + username: config.username, credentials: config.credentials, + expectedHostKeys: config.expectedHostKeys, + proxy: config.proxy, jumps: config.jumps, columns: UInt16(clamping: viewport.columns), + rows: UInt16(clamping: viewport.rows)) + } else { + let client = try await RemoteConnectionCoordinator().connectSFTP(using: config) + guard generation == token, !Task.isCancelled else { + await client.disconnect() + return + } + sftp = client + path = + host.sftpSessionInitialPath.isEmpty + ? try await client.homeDirectory() : host.sftpSessionInitialPath + files = try await client.listDirectory(at: path) + } + guard generation == token, !Task.isCancelled else { return } + transferConfiguration = config + submissions.removeAll() + connected = true + status = String(localized: "Connected") + } catch { + guard generation == token else { return } + self.error = error.localizedDescription + status = String(localized: "Disconnected") + if let sshError = error as? TermindSSHError { + switch sshError { + case .missingCredential, .missingUsername, .keyPassphraseRequired, .authenticationFailed: + authenticationRequired = true + default: authenticationRequired = sshError.shouldRequestAuthentication + } + if authenticationRequired { + authenticationHostID = sshError.routeHostID ?? descriptor.hostID + if let endpoint = hosts.host(authenticationHostID ?? descriptor.hostID) { + username = + submissions[endpoint.id]?.username ?? hosts.identity(for: endpoint)?.username ?? "" + } + password = "" + passphrase = "" + } + } + await ssh?.disconnect() + await sftp?.disconnect() + ssh = nil + sftp = nil + } + } + } + + func submitAuthentication() { + let id = authenticationHostID ?? descriptor.hostID + guard let host = hosts.host(id) else { return } + submissions[id] = SSHAuthenticationSubmission( + username: username, + password: password.isEmpty ? nil : password, passphrase: passphrase.isEmpty ? nil : passphrase, + keyID: hosts.identity(for: host)?.keyID) + password = "" + passphrase = "" + authenticationRequired = false + connect() + } + + func cancelAuthentication() { + password = "" + passphrase = "" + submissions.removeAll() + authenticationRequired = false + } + + func trustHostKey() { + guard let observation = pendingHostKey else { return } + hosts.trust( + KnownHostEntry( + hostname: observation.hostname, port: observation.port, + algorithm: observation.algorithm, fingerprint: observation.fingerprint, addedAt: .now)) + pendingHostKey = nil + connect() + } + + func disconnect() { + generation = UUID() + attempt?.cancel() + attempt = nil + transferConfiguration = nil + let oldSSH = ssh + let oldSFTP = sftp + ssh = nil + sftp = nil + Task { + await oldSSH?.disconnect() + await oldSFTP?.disconnect() + } + busy = false + connected = false + password = "" + passphrase = "" + submissions.removeAll() + authenticationRequired = false + pendingHostKey = nil + status = String(localized: "Disconnected") + } + + func openDirectory(_ directory: String) async { + guard let sftp else { return } + let connection = generation + let request = UUID() + directoryRequest = request + do { + let entries = try await sftp.listDirectory(at: directory) + guard generation == connection, directoryRequest == request else { return } + path = directory + files = entries.sorted { ($0.isDirectory ? 0 : 1, $0.name) < ($1.isDirectory ? 0 : 1, $1.name) } + } catch { + guard generation == connection, directoryRequest == request else { return } + self.error = error.localizedDescription + } + } + + func rename(_ file: RemoteFile, to name: String) async throws { + guard validName(name) else { throw CocoaError(.fileWriteInvalidFileName) } + guard let sftp else { throw TermindSSHError.disconnected } + let connection = generation + try await sftp.rename( + from: file.path, to: RemotePath.join(RemotePath.parent(of: file.path), name), overwrite: false) + guard connection == generation else { throw CancellationError() } + await openDirectory(path) + } + + func setPermissions(_ file: RemoteFile, mode: UInt32) async throws { + guard let sftp else { throw TermindSSHError.disconnected } + let connection = generation + try await sftp.setPermissions(at: file.path, to: mode & 0o7777) + guard connection == generation else { throw CancellationError() } + await openDirectory(path) + } + + func createDirectory(_ name: String) async { + guard let sftp, validName(name) else { return } + do { + try await sftp.createDirectory(at: RemotePath.join(path, name)) + await openDirectory(path) + } catch { self.error = error.localizedDescription } + } + + func delete(_ file: RemoteFile) async { + guard let sftp else { return } + do { + if file.isDirectory { + try await sftp.removeDirectory(at: file.path) + } else { + try await sftp.removeFile(at: file.path) + } + await openDirectory(path) + } catch { self.error = error.localizedDescription } + } + + func upload(_ url: URL) async { + guard let transfers, let configuration = transferConfiguration, connected else { return } + let scoped = url.startAccessingSecurityScopedResource() + defer { if scoped { url.stopAccessingSecurityScopedResource() } } + do { + guard validName(url.lastPathComponent) else { throw CocoaError(.fileReadInvalidFileName) } + let attributes = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]) + guard attributes.isRegularFile == true else { throw CocoaError(.fileReadUnsupportedScheme) } + let connection = generation + transfers.enqueue( + direction: .upload, batchID: UUID(), localURL: url, + remotePath: RemotePath.join(path, url.lastPathComponent), byteCount: Int64(attributes.fileSize ?? 0), + hostID: descriptor.hostID, configuration: configuration, scopedRoot: url, overwrite: false, + completion: { [weak self] state in + guard let self, self.generation == connection, state == .completed else { return } + Task { await self.openDirectory(self.path) } + }) + } catch { self.error = error.localizedDescription } + } + + func download(_ file: RemoteFile) { + guard let transfers, let configuration = transferConfiguration, connected, !file.isDirectory else { return } + do { + guard validName(file.name) else { throw CocoaError(.fileWriteInvalidFileName) } + let directory = FileManager.default.temporaryDirectory.appending( + path: "TermindDownloads", directoryHint: .isDirectory + ) + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + transfers.enqueue( + direction: .download, batchID: UUID(), localURL: directory.appending(path: file.name), + remotePath: file.path, byteCount: file.size, hostID: descriptor.hostID, + configuration: configuration, scopedRoot: directory, overwrite: false) + } catch { self.error = error.localizedDescription } + } + + private func validName(_ name: String) -> Bool { + !name.isEmpty && name != "." && name != ".." && !name.contains("/") + && !name.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) + } + + private func resize(_ size: InMemoryTerminalViewport) async { + viewport = size + await ssh?.resize( + columns: UInt16(clamping: size.columns), rows: UInt16(clamping: size.rows), + widthPixels: UInt32(clamping: size.widthPixels), heightPixels: UInt32(clamping: size.heightPixels)) + } +} + +@MainActor +private final class MobileTerminalRelay { + weak var owner: MobileWorkspace? +} diff --git a/TFI/App/MobileWorkspaceDescriptor.swift b/TFI/App/MobileWorkspaceDescriptor.swift new file mode 100644 index 00000000..bbd03af8 --- /dev/null +++ b/TFI/App/MobileWorkspaceDescriptor.swift @@ -0,0 +1,16 @@ +import Foundation + +nonisolated struct MobileWorkspaceDescriptor: Codable, Hashable, Identifiable, Sendable { + enum Kind: String, Codable, CaseIterable, Sendable { + case ssh, sftp + var title: String { self == .ssh ? "SSH" : "SFTP" } + var symbol: String { self == .ssh ? "terminal" : "folder" } + } + + var id = UUID() + var hostID: UUID + var title: String + var kind: Kind + + var destinationURL: URL { WorkspaceActivityAttributes.destinationURL(for: id) } +} diff --git a/TFI/App/TermindIOSApp.swift b/TFI/App/TermindIOSApp.swift index ef072669..120926bd 100644 --- a/TFI/App/TermindIOSApp.swift +++ b/TFI/App/TermindIOSApp.swift @@ -3,10 +3,13 @@ import SwiftUI @main struct TermindIOSApp: App { + @AppStorage("mobile.appearance") private var appearance = "system" private let startup: Result init() { - startup = Result { try MobileAppModel() } + startup = Result { + try MobileAppModel(inMemory: ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil) + } } var body: some Scene { @@ -15,6 +18,7 @@ struct TermindIOSApp: App { case .success(let app): MobileRootView(app: app) .modelContainer(app.container) + .preferredColorScheme(appearance == "dark" ? .dark : appearance == "light" ? .light : nil) case .failure: ContentUnavailableView( "Unable to Open Termind", systemImage: "exclamationmark.triangle", diff --git a/TFI/Features/MobileAISettingsView.swift b/TFI/Features/MobileAISettingsView.swift new file mode 100644 index 00000000..3f8617e8 --- /dev/null +++ b/TFI/Features/MobileAISettingsView.swift @@ -0,0 +1,59 @@ +import AgentKit +import SwiftUI +import TermindApplication + +struct MobileAISettingsView: View { + let ai: AIConfigurationModel + @State private var editing: ModelProvider? + + var body: some View { + List { + ForEach(ai.modelProviders) { provider in + Button(provider.name) { editing = provider } + } + } + .navigationTitle("AI") + .toolbar { Button("Add", systemImage: "plus") { editing = ModelProvider(name: "") } } + .sheet(item: $editing) { MobileProviderEditor(ai: ai, provider: $0) } + } +} + +private struct MobileProviderEditor: View { + let ai: AIConfigurationModel + let original: ModelProvider + @State private var draft: ModelProvider + @State private var apiKey = "" + @State private var modelID: String + + init(ai: AIConfigurationModel, provider: ModelProvider) { + self.ai = ai + original = provider + _draft = State(initialValue: provider) + _modelID = State(initialValue: provider.models.first?.id ?? "") + } + + var body: some View { + MobileEditorSheet( + title: String(localized: "Model Provider"), + isDirty: draft != original || !apiKey.isEmpty || modelID != (original.models.first?.id ?? ""), + canSave: !draft.name.isEmpty && URL(string: draft.inferenceURL)?.scheme == "https" && !modelID.isEmpty, + save: { + if !draft.models.contains(where: { $0.id == modelID }) { draft.models.append(AIModel(id: modelID)) } + ai.upsert(draft) + if let error = ai.storageError { throw MobileEditorError.message(error) } + if !apiKey.isEmpty { ai.setSecret(apiKey, for: draft.effectiveCredentialRef) } + if let error = ai.storageError { throw MobileEditorError.message(error) } + ai.select(AIModelRef(modelProviderID: draft.id, modelID: modelID)) + }, + content: { + TextField("Name", text: $draft.name) + TextField("Inference URL", text: $draft.inferenceURL).textInputAutocapitalization(.never) + .autocorrectionDisabled().keyboardType(.URL) + Picker("API Format", selection: $draft.apiFormat) { + ForEach(ModelAPIFormat.allCases) { Text($0.rawValue).tag($0) } + } + SecureField("API Key", text: $apiKey) + TextField("Model ID", text: $modelID).textInputAutocapitalization(.never).autocorrectionDisabled() + }) + } +} diff --git a/TFI/Features/MobileAssistantView.swift b/TFI/Features/MobileAssistantView.swift new file mode 100644 index 00000000..dd30fedc --- /dev/null +++ b/TFI/Features/MobileAssistantView.swift @@ -0,0 +1,65 @@ +import AgentKit +import SwiftUI + +struct MobileAssistantView: View { + let app: MobileAppModel + @Bindable var model: MobileAssistantModel + @State private var history = false + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + ForEach(model.messages.filter { $0.role != .tool }) { message in + VStack(alignment: .leading, spacing: 6) { + Text(message.role == .user ? String(localized: "You") : String(localized: "Assistant")) + .font(.caption).foregroundStyle(.secondary) + Text(.init(message.text)).textSelection(.enabled) + } + .frame(maxWidth: .infinity, alignment: .leading).id(message.id) + } + if let error = model.error { Text(error).foregroundStyle(.red) } + }.padding() + } + .onChange(of: model.messages.count) { _, _ in + if let id = model.messages.last?.id { proxy.scrollTo(id, anchor: .bottom) } + } + } + .safeAreaInset(edge: .bottom) { + HStack(alignment: .bottom) { + TextField("Message", text: $model.draft, axis: .vertical).lineLimit(1...6) + .textFieldStyle(.roundedBorder) + if model.isRunning { + Button("Stop", systemImage: "stop.fill") { model.stop() }.labelStyle(.iconOnly) + } else { + Button("Send", systemImage: "arrow.up.circle.fill") { + if app.pro.require(.assistant) { model.send() } + }.labelStyle(.iconOnly).disabled( + model.draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + }.padding().background(.bar) + } + .navigationTitle("Assistant") + .toolbar { + Button("Conversations", systemImage: "clock") { history = true }.disabled(model.isRunning) + Button("New Conversation", systemImage: "square.and.pencil") { model.newConversation() }.disabled( + model.isRunning) + } + .sheet(isPresented: $history) { + NavigationStack { + List(model.conversations, id: \.id) { conversation in + Button( + conversation.title.isEmpty + ? String(conversation.messages.first?.text.prefix(80) ?? "") : conversation.title + ) { + model.select(conversation) + history = false + } + } + .navigationTitle("Conversations") + .toolbar { Button("Done") { history = false } } + }.presentationDetents([.medium, .large]) + } + .task { await model.load() } + } +} diff --git a/TFI/Features/MobileCredentialsView.swift b/TFI/Features/MobileCredentialsView.swift new file mode 100644 index 00000000..1c3a898e --- /dev/null +++ b/TFI/Features/MobileCredentialsView.swift @@ -0,0 +1,135 @@ +import SwiftUI +import TermindApplication +import TermindDomain + +struct MobileCredentialsView: View { + let app: MobileAppModel + @State private var editing: Identity? + @State private var keyEditor: MobileKeyEditorModel? + @State private var deletingKey: SSHKeyRef? + @State private var deletingIdentity: Identity? + @State private var error: String? + + var body: some View { + List { + Section("Identities") { + ForEach(app.credentials.identities) { identity in + Button { + editing = identity + } label: { + VStack(alignment: .leading) { + Text(identity.name) + Text(identity.username).font(.caption).foregroundStyle(.secondary) + } + } + .swipeActions { Button("Delete", role: .destructive) { deletingIdentity = identity } } + } + } + Section("Keys") { + ForEach(app.credentials.keys) { key in + Button { + keyEditor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key) + } label: { + VStack(alignment: .leading) { + Text(key.label) + Text(key.fingerprint).font(.caption.monospaced()).foregroundStyle(.secondary) + } + }.swipeActions { Button("Delete", role: .destructive) { deletingKey = key } } + } + } + } + .navigationTitle("Keychain") + .toolbar { + Menu { + Button("New Identity") { editing = Identity(name: "", username: "") } + Button("Generate Key") { + keyEditor = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) + } + Button("Import Key") { + keyEditor = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) + } + } label: { + Label("Add", systemImage: "plus") + } + } + .sheet(item: $editing) { MobileIdentityEditor(app: app, identity: $0) } + .sheet(item: $keyEditor) { MobileKeyEditor(model: $0) } + .confirmationDialog( + "Delete Identity?", + isPresented: Binding(get: { deletingIdentity != nil }, set: { if !$0 { deletingIdentity = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let identity = deletingIdentity, !app.credentials.delete(identityID: identity.id) { + error = app.credentials.storageError + } + deletingIdentity = nil + } + } message: { + Text("Hosts using this identity will ask for credentials again.") + } + .confirmationDialog( + "Delete Key?", isPresented: Binding(get: { deletingKey != nil }, set: { if !$0 { deletingKey = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let key = deletingKey { + do { try app.credentials.delete(keyID: key.id) } catch { self.error = error.localizedDescription } + } + deletingKey = nil + } + } message: { + Text("Identities using this key will no longer have access to it.") + } + .alert("Unable to Delete", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) { error = nil } + } message: { + Text(error ?? "") + } + } +} + +private struct MobileIdentityEditor: View { + let app: MobileAppModel + let original: Identity + @State private var draft: Identity + @State private var password = "" + @State private var removePassword = false + + init(app: MobileAppModel, identity: Identity) { + self.app = app + original = identity + _draft = State(initialValue: identity) + } + + var body: some View { + MobileEditorSheet( + title: String(localized: "Identity"), + isDirty: draft != original || !password.isEmpty || removePassword, + canSave: !draft.name.isEmpty && !draft.username.isEmpty, + save: { + try app.library.upsert( + draft, passwordUpdate: removePassword ? .remove : password.isEmpty ? .keep : .replace(password)) + }, + content: { + Section("Identity") { + TextField("Name", text: $draft.name) + TextField("Username", text: $draft.username).textInputAutocapitalization(.never) + .autocorrectionDisabled() + } + Section("Authentication") { + SecureField("Password", text: $password).textContentType(.password) + if original.usesPassword { Toggle("Remove Password", isOn: $removePassword) } + Picker("Storage", selection: $draft.passwordStorage) { + Text("This Device").tag(PasswordStorage.thisDevice) + Text("iCloud").tag(PasswordStorage.iCloud) + } + Picker("Key", selection: $draft.keyID) { + Text("None").tag(UUID?.none) + ForEach(app.credentials.keys) { Text($0.label).tag(Optional($0.id)) } + } + } + Section("Notes") { TextEditor(text: $draft.notes).frame(minHeight: 100) } + }) + } +} diff --git a/TFI/Features/MobileEditorSheet.swift b/TFI/Features/MobileEditorSheet.swift new file mode 100644 index 00000000..ad781391 --- /dev/null +++ b/TFI/Features/MobileEditorSheet.swift @@ -0,0 +1,59 @@ +import SwiftUI + +/// Presentation is mobile-owned; form content can also live in desktop work columns. +struct MobileEditorSheet: View { + let title: String + let isDirty: Bool + var canSave = true + var compact = false + let save: () async throws -> Void + @ViewBuilder let content: () -> Content + @Environment(\.dismiss) private var dismiss + @Environment(\.dynamicTypeSize) private var typeSize + @State private var discard = false + @State private var error: String? + @State private var isSaving = false + @State private var saveTask: Task? + + var body: some View { + NavigationStack { + Form { content().disabled(isSaving) } + .navigationTitle(title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { if isDirty { discard = true } else { dismiss() } }.disabled(isSaving) + } + ToolbarItem(placement: .confirmationAction) { + Button { + guard !isSaving else { return } + isSaving = true + saveTask = Task { + defer { isSaving = false } + do { + try await save() + try Task.checkCancellation() + dismiss() + } catch is CancellationError { + } catch { self.error = error.localizedDescription } + } + } label: { + if isSaving { ProgressView() } else { Text("Save") } + }.disabled(!canSave || isSaving) + } + } + .confirmationDialog("Discard Changes?", isPresented: $discard, titleVisibility: .visible) { + Button("Discard Changes", role: .destructive) { dismiss() } + } + .alert("Unable to Save", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) { error = nil } + } message: { + Text(error ?? "") + } + } + .presentationDetents(compact && !typeSize.isAccessibilitySize ? [.medium, .large] : [.large]) + .presentationDragIndicator(.visible) + .interactiveDismissDisabled(isDirty || isSaving) + .onDisappear { saveTask?.cancel() } + } +} diff --git a/TFI/Features/MobileForwardingsView.swift b/TFI/Features/MobileForwardingsView.swift new file mode 100644 index 00000000..be08c245 --- /dev/null +++ b/TFI/Features/MobileForwardingsView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import TermindDomain +import TermindPresentation + +struct MobileForwardingsView: View { + let app: MobileAppModel + @State private var editing: PortForwardingRule? + @State private var removing: PortForwardingRule? + @State private var error: String? + + var body: some View { + List(app.forwardingLibrary.rules) { rule in + VStack(alignment: .leading, spacing: 8) { + Text(rule.label.isEmpty ? rule.bindAddress : rule.label).font(.headline) + Text(app.library.host(rule.hostID)?.name ?? "").font(.caption) + Text(app.forwardings.state(for: rule.id).label).font(.caption).foregroundStyle(.secondary) + HStack { + Button(app.forwardings.isEnabled(rule.id) ? String(localized: "Stop") : String(localized: "Start")) + { + if app.forwardings.isEnabled(rule.id) { + app.forwardings.stop(rule.id) + } else { + app.forwardings.start(rule.id) + } + } + Button("Edit") { editing = rule } + }.buttonStyle(.bordered) + } + .swipeActions { Button("Delete", role: .destructive) { removing = rule } } + } + .navigationTitle("Port Forwarding") + .toolbar { + Button("Add", systemImage: "plus") { + if let host = app.library.hosts.first { editing = PortForwardingRule(hostID: host.id) } + }.disabled(app.library.hosts.isEmpty) + } + .sheet(item: $editing) { MobileForwardingEditor(app: app, rule: $0) } + .confirmationDialog( + "Delete Forwarding Rule?", + isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let rule = removing { + do { try app.forwardingLibrary.delete(rule.id) } catch { self.error = error.localizedDescription } + } + removing = nil + } + } + .alert("Unable to Save", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { + Button("OK", role: .cancel) { error = nil } + } message: { + Text(error ?? "") + } + } +} + +private struct MobileForwardingEditor: View { + let app: MobileAppModel + let original: PortForwardingRule + @State private var draft: PortForwardingRule + + init(app: MobileAppModel, rule: PortForwardingRule) { + self.app = app + original = rule + _draft = State(initialValue: rule) + } + + var body: some View { + MobileEditorSheet( + title: String(localized: "Port Forwarding"), isDirty: draft != original, + canSave: draft.isValid, save: { try app.forwardingLibrary.save(draft) }, + content: { + Picker("Host", selection: $draft.hostID) { + ForEach(app.library.hosts) { Text($0.name).tag($0.id) } + }.disabled(app.forwardingLibrary.portForwarding(original.id) != nil) + PortForwardingFields(rule: $draft) + }) + } +} diff --git a/TFI/Features/MobileHostEditor.swift b/TFI/Features/MobileHostEditor.swift new file mode 100644 index 00000000..d9764748 --- /dev/null +++ b/TFI/Features/MobileHostEditor.swift @@ -0,0 +1,60 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import TermindPresentation + +import struct TermindDomain.Host + +struct MobileHostEditor: View { + let library: HostLibraryModel + let original: Host + @State private var draft: Host + + init(library: HostLibraryModel, host: Host) { + self.library = library + original = host + _draft = State(initialValue: host) + } + + var body: some View { + MobileEditorSheet( + title: String(localized: "Host"), isDirty: draft != original, + canSave: !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && (1...65535).contains(draft.port), + save: { + if draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { draft.name = draft.host } + _ = library.upsert(draft, replacingPortForwardings: library.portForwardings(for: draft.id)) + if let error = library.storageError { throw MobileEditorError.message(error) } + }, + content: { + HostConnectionFields(host: $draft) + Section("Authentication") { + Picker("Identity", selection: $draft.identityID) { + Text("None").tag(UUID?.none) + ForEach(library.identities) { Text($0.name).tag(Optional($0.id)) } + } + } + Section("Routing") { + Picker("Jump Host", selection: $draft.jumpHostID) { + Text("None").tag(UUID?.none) + ForEach(library.hosts.filter { $0.id != draft.id }) { Text($0.name).tag(Optional($0.id)) } + } + } + Section("Group") { + Picker("Group", selection: $draft.groupID) { + Text("None").tag(UUID?.none) + ForEach(library.groups) { Text($0.name).tag(Optional($0.id)) } + } + } + }) + } +} + +nonisolated enum MobileEditorError: LocalizedError { + case message(String) + var errorDescription: String? { + switch self { + case .message(let value): value + } + } +} diff --git a/TFI/Features/MobileKeyEditor.swift b/TFI/Features/MobileKeyEditor.swift new file mode 100644 index 00000000..b41608f9 --- /dev/null +++ b/TFI/Features/MobileKeyEditor.swift @@ -0,0 +1,80 @@ +import SwiftUI +import TermindDomain +import UniformTypeIdentifiers + +struct MobileKeyEditor: View { + @Bindable var model: MobileKeyEditorModel + @State private var choosingFile = false + @State private var fileError: String? + + var body: some View { + MobileEditorSheet( + title: title, isDirty: model.isDirty, canSave: model.canSave, + save: { try await model.save() }, + content: { + Section("Key") { + TextField("Name", text: $model.label) + if !model.isEnclave { + Picker("Storage", selection: $model.storage) { + Text("This Device").tag(KeyStorage.thisDevice) + Text("iCloud").tag(KeyStorage.iCloud) + if model.mode == .generate && model.canUseEnclave { + Text("Secure Enclave").tag(KeyStorage.secureEnclave) + } + } + } + if model.mode == .generate { + if model.storage == .secureEnclave { + Toggle("Require User Presence", isOn: $model.requiresUserPresence) + } else { + Picker("Algorithm", selection: $model.algorithm) { + ForEach(MobileKeyEditorModel.Algorithm.allCases) { Text($0.rawValue).tag($0) } + } + SecureField("Passphrase", text: $model.passphrase) + } + } + } + if model.mode == .importKey { + Section("Private Key") { + Button("Choose File") { choosingFile = true } + TextEditor(text: $model.privateKey).font(.body.monospaced()).frame(minHeight: 150) + .textInputAutocapitalization(.never).autocorrectionDisabled() + SecureField("Passphrase", text: $model.passphrase) + } + } + if model.storage != .secureEnclave { + Section("SSH Certificate") { + TextEditor(text: $model.certificate).font(.caption.monospaced()).frame(minHeight: 90) + .textInputAutocapitalization(.never).autocorrectionDisabled() + } + } + if let publicKey = model.publicKey { + Section("Public Key") { Text(publicKey).font(.caption.monospaced()).textSelection(.enabled) } + } + if let fingerprint = model.fingerprint { + Section("Fingerprint") { Text(fingerprint).font(.caption.monospaced()).textSelection(.enabled) } + } + } + ) + .fileImporter(isPresented: $choosingFile, allowedContentTypes: [.data]) { result in + Task { + do { try await model.loadFile(result.get()) } catch { fileError = error.localizedDescription } + } + } + .alert( + "Unable to Read File", isPresented: Binding(get: { fileError != nil }, set: { if !$0 { fileError = nil } }) + ) { + Button("OK", role: .cancel) { fileError = nil } + } message: { + Text(fileError ?? "") + } + } + + private var title: String { + switch model.mode { + case .generate: String(localized: "Generate Key") + case .importKey: String(localized: "Import Key") + case .edit: String(localized: "Edit Key") + } + } +} diff --git a/TFI/Features/MobileKnownHostsView.swift b/TFI/Features/MobileKnownHostsView.swift new file mode 100644 index 00000000..f8ff6502 --- /dev/null +++ b/TFI/Features/MobileKnownHostsView.swift @@ -0,0 +1,29 @@ +import SwiftUI +import TermindDomain + +struct MobileKnownHostsView: View { + let app: MobileAppModel + @State private var removing: KnownHostEntry? + + var body: some View { + List(app.knownHosts.entries) { entry in + VStack(alignment: .leading, spacing: 6) { + Label( + entry.displayAddress, + systemImage: entry.isMismatched ? "exclamationmark.shield" : "checkmark.shield") + Text(entry.fingerprint).font(.caption.monospaced()).textSelection(.enabled) + } + .swipeActions { Button("Delete", role: .destructive) { removing = entry } } + } + .navigationTitle("Known Hosts") + .confirmationDialog( + "Delete Known Host?", isPresented: Binding(get: { removing != nil }, set: { if !$0 { removing = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let entry = removing { _ = app.knownHosts.delete([entry.id]) } + removing = nil + } + } + } +} diff --git a/TFI/Features/MobileMetricsView.swift b/TFI/Features/MobileMetricsView.swift new file mode 100644 index 00000000..ee95ea3a --- /dev/null +++ b/TFI/Features/MobileMetricsView.swift @@ -0,0 +1,43 @@ +import SwiftUI +import TermindPresentation + +struct MobileMetricsView: View { + let app: MobileAppModel + @State private var selecting = false + + var body: some View { + List { + ForEach(app.metrics.selections) { selection in + Section(app.library.host(selection.id)?.name ?? "") { + if let point = app.metrics.points[selection.id] { + MetricsSummaryView(point: point) + } else if let error = app.metrics.errors[selection.id] { + Text(error).foregroundStyle(.secondary) + } else { + ProgressView() + } + Button("Remove", role: .destructive) { app.metrics.remove(selection.id) } + } + } + } + .navigationTitle("Metrics") + .toolbar { Button("Add", systemImage: "plus") { selecting = true } } + .sheet(isPresented: $selecting) { + NavigationStack { + List(app.library.hosts) { host in + Button(host.name) { + selecting = false + if app.metrics.add(host.id, pro: app.pro.hasProAccess) { + app.metrics.start(hosts: app.library) + } else { + app.pro.presentPaywall(for: .unlimitedMetrics) + } + } + } + .navigationTitle("Hosts") + .toolbar { Button("Cancel") { selecting = false } } + }.presentationDetents([.medium, .large]) + } + .task { app.metrics.start(hosts: app.library) } + } +} diff --git a/TFI/Features/MobileProView.swift b/TFI/Features/MobileProView.swift new file mode 100644 index 00000000..97462d4a --- /dev/null +++ b/TFI/Features/MobileProView.swift @@ -0,0 +1,39 @@ +import SwiftUI +import TermindInfrastructure +import TermindRemoteInfrastructure + +struct MobileProView: View { + @Bindable var pro: ProAccessController + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + Form { + Section("Termind Pro") { + Label("Assistant", systemImage: "sparkles") + Label("iCloud Sync", systemImage: "icloud") + Label("Unlimited Metrics", systemImage: "gauge.with.dots.needle.33percent") + Label("Remote MCP", systemImage: "network") + } + Section { + if pro.hasProAccess { + Label("Pro Active", systemImage: "checkmark.seal.fill") + } else { + Button { + Task { await pro.purchase() } + } label: { + LabeledContent("Upgrade to Pro", value: pro.displayPrice ?? "") + } + .disabled(!pro.canPurchase) + if pro.canStartTrial { Button("Start Free Trial") { Task { await pro.startTrial() } } } + } + Button("Restore Purchases") { Task { await pro.restorePurchases() } }.disabled(pro.isProcessing) + if pro.isProcessing { ProgressView() } + if let message = pro.message { Text(message).foregroundStyle(.secondary) } + } + } + .navigationTitle("Termind Pro") + .toolbar { Button("Done") { dismiss() } } + }.presentationDetents([.large]) + } +} diff --git a/TFI/Features/MobileRemoteFileEditor.swift b/TFI/Features/MobileRemoteFileEditor.swift new file mode 100644 index 00000000..9fbda4a5 --- /dev/null +++ b/TFI/Features/MobileRemoteFileEditor.swift @@ -0,0 +1,45 @@ +import SwiftUI +import TermindDomain +import TermindPresentation +import TermindRemoteInfrastructure + +struct MobileRemoteFileEditor: View { + enum Mode { case rename, permissions } + let workspace: MobileWorkspace + let file: RemoteFile + let mode: Mode + @State private var name: String + @State private var permissions: SFTPPermissionDraft + + init(workspace: MobileWorkspace, file: RemoteFile, mode: Mode) { + self.workspace = workspace + self.file = file + self.mode = mode + _name = State(initialValue: file.name) + _permissions = State(initialValue: SFTPPermissionDraft(permissions: file.permissions)) + } + + var body: some View { + MobileEditorSheet( + title: mode == .rename ? String(localized: "Rename") : String(localized: "Edit Permissions"), + isDirty: name != file.name || permissions.text != SFTPPermissionDraft.octalString(for: file.permissions), + canSave: workspace.connected + && (mode == .rename ? !name.isEmpty && name != file.name : permissions.canSave), + compact: mode == .rename, + save: { + if mode == .rename { + try await workspace.rename(file, to: name) + } else { + try await workspace.setPermissions(file, mode: permissions.mode) + } + }, + content: { + Section { Text(file.path).font(.caption.monospaced()).textSelection(.enabled) } + if mode == .rename { + TextField("Name", text: $name).textInputAutocapitalization(.never).autocorrectionDisabled() + } else { + UnixPermissionFields(draft: $permissions) + } + }) + } +} diff --git a/TFI/Features/MobileRootView.swift b/TFI/Features/MobileRootView.swift index c5f83a14..ed8994a0 100644 --- a/TFI/Features/MobileRootView.swift +++ b/TFI/Features/MobileRootView.swift @@ -1,101 +1,178 @@ import SwiftUI -import TermindApplication -import TermindDomain - -import struct TermindDomain.Host +/// iPhone has four destinations. iPad retains the sidebar with individual library routes. struct MobileRootView: View { let app: MobileAppModel - @State private var selection: MobileDestination? - - var body: some View { - NavigationSplitView { - List(MobileDestination.allCases, selection: $selection) { destination in - NavigationLink(value: destination) { - Label(destination.title, systemImage: destination.symbol) - } - } - .navigationTitle("Termind") - } detail: { - if let selection { - destination(selection) - .navigationTitle(selection.title) - } else { - ContentUnavailableView( - "Termind", systemImage: "terminal", description: Text("Select a section to begin.")) - } - } - } - - @ViewBuilder - private func destination(_ destination: MobileDestination) -> some View { - switch destination { - case .hosts: - MobileHostsView(repository: app.hosts) - case .assistant: - ContentUnavailableView( - "Assistant", systemImage: "sparkles", - description: Text("Assistant will be available in a future update.")) - case .settings: - Form { - LabeledContent( - "Version", - value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0") - } - } - } -} - -private struct MobileHostsView: View { - let repository: any HostRepository - @State private var snapshot = HostLibrarySnapshot.empty - @State private var failed = false + @Environment(\.horizontalSizeClass) private var sizeClass + @Environment(\.scenePhase) private var scenePhase + @State private var sceneID = UUID() + @State private var tab: MobileTab = .home + @State private var selection: MobileDestination? = .welcome + @State private var workspaceID: UUID? + @State private var unavailable = false + @State private var showingWorkspaces = false var body: some View { Group { - if failed { - ContentUnavailableView("Unable to Load Hosts", systemImage: "exclamationmark.triangle") - } else if snapshot.hosts.isEmpty { - ContentUnavailableView( - "No Hosts", systemImage: "server.rack", - description: Text("Host management will be available in a future update.")) + if sizeClass == .compact { + TabView(selection: $tab) { + NavigationStack { home } + .tabItem { Label("Welcome", systemImage: "house") }.tag(MobileTab.home) + NavigationStack { metrics } + .tabItem { Label("Metrics", systemImage: "gauge.with.dots.needle.33percent") }.tag( + MobileTab.metrics) + NavigationStack { assistant } + .tabItem { Label("Assistant", systemImage: "sparkles") }.tag(MobileTab.assistant) + NavigationStack { settings } + .tabItem { Label("Settings", systemImage: "gearshape") }.tag(MobileTab.settings) + } } else { - List(snapshot.hosts) { host in - VStack(alignment: .leading) { - Text(host.name) - Text(host.displayAddress).font(.caption).foregroundStyle(.secondary) + NavigationSplitView { + List(selection: $selection) { + ForEach(MobileDestination.allCases) { destination in + NavigationLink(value: destination) { + Label(destination.title, systemImage: destination.symbol) + } + } + }.navigationTitle("Termind") + } detail: { + NavigationStack { + switch selection { + case .hosts: MobileVaultView(app: app, open: open) + case .assistant: assistant + case .settings: settings + case .metrics: metrics + case .workspaces: workspaceList + default: home + } } } } } - .task { - do { - snapshot = try repository.snapshot().value - for await change in repository.changes() { - guard !Task.isCancelled else { return } - snapshot = change.value + .sheet(isPresented: Binding(get: { app.pro.isPaywallPresented }, set: { app.pro.isPaywallPresented = $0 })) { + MobileProView(pro: app.pro) + } + .onChange(of: scenePhase, initial: true) { _, phase in app.sceneChanged(sceneID, phase: phase) } + .onDisappear { app.removeScene(sceneID) } + .onOpenURL { url in + if WorkspaceActivityAttributes.accepts(url) { + tab = .home + selection = .workspaces + workspaceID = nil + showingWorkspaces = sizeClass == .compact + return + } + guard let id = WorkspaceActivityAttributes.workspaceID(from: url) else { return } + tab = .home + selection = .workspaces + if let workspace = app.workspaces.first(where: { $0.id == id }) { + workspaceID = id + app.liveActivities.focus(id, in: app.workspaces) + workspace.connect() + workspace.terminal.requestFocus() + } else { + unavailable = true + } + } + .alert( + unavailable ? String(localized: "Workspace Unavailable") : String(localized: "Operation Failed"), + isPresented: Binding( + get: { unavailable || app.error != nil }, + set: { + if !$0 { + unavailable = false + app.error = nil + } + }) + ) { + Button("OK", role: .cancel) { + unavailable = false + app.error = nil + } + } message: { + Text( + unavailable ? String(localized: "This workspace was closed or its host was removed.") : app.error ?? "") + } + + } + + private var home: some View { + List { + Section { + NavigationLink { + MobileVaultView(app: app, open: open) + } label: { + Label("Vault", systemImage: "server.rack") } - } catch { - failed = true + if sizeClass != .compact { + NavigationLink { + metrics + } label: { + Label("Metrics", systemImage: "gauge.with.dots.needle.33percent") + } + } + } + Section("Workspaces") { workspaceRows } + } + .navigationTitle("Welcome") + .navigationDestination(isPresented: $showingWorkspaces) { workspaceList } + .navigationDestination(item: $workspaceID) { id in + if let workspace = app.workspaces.first(where: { $0.id == id }) { + MobileWorkspaceView(workspace: workspace) } } } -} -extension MobileDestination { - fileprivate var title: String { - switch self { - case .hosts: String(localized: "Hosts") - case .assistant: String(localized: "Assistant") - case .settings: String(localized: "Settings") + private var workspaceList: some View { + List { workspaceRows } + .navigationTitle("Workspaces") + .navigationDestination(item: $workspaceID) { id in + if let workspace = app.workspaces.first(where: { $0.id == id }) { + MobileWorkspaceView(workspace: workspace) + } + } + } + + @ViewBuilder private var workspaceRows: some View { + if app.workspaces.isEmpty { + Text("Open a host from Vault to start a workspace.").foregroundStyle(.secondary) + } + ForEach(app.workspaces) { workspace in + Button { + workspaceID = workspace.id + app.liveActivities.focus(workspace.id, in: app.workspaces) + } label: { + Label { + VStack(alignment: .leading) { + Text(workspace.descriptor.title) + Text(workspace.status).font(.caption).foregroundStyle(.secondary) + } + } icon: { + Image(systemName: workspace.descriptor.kind.symbol) + } + } + .swipeActions { Button("Close", role: .destructive) { app.close(workspace.id) } } } } - fileprivate var symbol: String { - switch self { - case .hosts: "server.rack" - case .assistant: "sparkles" - case .settings: "gearshape" - } + private var assistant: some View { + MobileAssistantView(app: app, model: app.assistant) + } + + private var metrics: some View { + MobileMetricsView(app: app) + } + + private var settings: some View { + MobileSettingsView(app: app) + } + + private func open(_ hostID: UUID, _ kind: MobileWorkspaceDescriptor.Kind) { + workspaceID = app.open(hostID: hostID, kind: kind) + if sizeClass != .compact { selection = .workspaces } } } + +nonisolated enum MobileTab: String, CaseIterable { + case home, metrics, assistant, settings +} diff --git a/TFI/Features/MobileSSHImportView.swift b/TFI/Features/MobileSSHImportView.swift new file mode 100644 index 00000000..f198ead3 --- /dev/null +++ b/TFI/Features/MobileSSHImportView.swift @@ -0,0 +1,141 @@ +import SwiftUI +import TermindApplication +import TermindDomain +import UniformTypeIdentifiers + +struct MobileSSHImportView: View { + @Bindable var model: SSHImportModel + @Environment(\.dismiss) private var dismiss + @State private var choosingSource = false + @State private var knownHostsOnly = false + @State private var authorizing = false + @State private var unlockID: UUID? + @State private var passphrase = "" + @State private var fileError: String? + + var body: some View { + NavigationStack { + List { + Section { + Toggle("Known Hosts Only", isOn: $knownHostsOnly).disabled(!model.items.isEmpty) + Button("Choose SSH Files") { choosingSource = true } + if let path = model.sourcePath { Text(path).font(.caption).foregroundStyle(.secondary) } + Picker("Key Storage", selection: $model.storage) { + Text("This Device").tag(KeyStorage.thisDevice) + Text("iCloud").tag(KeyStorage.iCloud) + } + } + ForEach(SSHImportCategory.allCases) { category in + Section(category.title) { + ForEach($model.items) { $item in + if item.category == category { row($item) } + } + } + } + if let error = model.error { Text(error).foregroundStyle(.red) } + if let error = fileError { Text(error).foregroundStyle(.red) } + } + .disabled(model.isWorking) + .navigationTitle("Import SSH Files") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + Task { + await model.discard() + dismiss() + } + }.disabled(model.isWorking) + } + ToolbarItem(placement: .confirmationAction) { + if model.isWorking { + ProgressView() + } else { + Button("Import") { + Task { + await model.commit() + if model.completed { dismiss() } + } + } + .disabled(!model.canImport) + } + } + } + } + .presentationDetents([.large]) + .interactiveDismissDisabled(model.isWorking) + .onDisappear { Task { await model.discard() } } + .fileImporter(isPresented: $choosingSource, allowedContentTypes: knownHostsOnly ? [.data] : [.folder]) { + result in + Task { + do { await model.scan(try result.get(), knownHostsOnly: knownHostsOnly) } catch { + fileError = error.localizedDescription + } + } + } + .fileImporter(isPresented: $authorizing, allowedContentTypes: [.data, .folder]) { result in + Task { + do { await model.authorize(try result.get()) } catch { fileError = error.localizedDescription } + } + } + .alert( + "Passphrase", + isPresented: Binding( + get: { unlockID != nil }, + set: { + if !$0 { + unlockID = nil + passphrase = "" + } + }) + ) { + SecureField("Passphrase", text: $passphrase) + Button("Unlock") { + guard let id = unlockID else { return } + let value = passphrase + passphrase = "" + unlockID = nil + Task { await model.unlock(id, passphrase: value) } + } + Button("Cancel", role: .cancel) { + passphrase = "" + unlockID = nil + } + } + } + + @ViewBuilder private func row(_ item: Binding) -> some View { + let value = item.wrappedValue + VStack(alignment: .leading, spacing: 8) { + Toggle(value.name, isOn: item.selected).disabled(!model.selectable(value)) + if value.host != nil { + TextField("Name", text: item.name) + TextField( + "Username", + text: Binding( + get: { item.wrappedValue.host?.username ?? "" }, set: { item.wrappedValue.host?.username = $0 }) + ) + .textInputAutocapitalization(.never).autocorrectionDisabled() + let keys = model.keyOptions(for: value) + if keys.count > 1 { + Picker("Key", selection: item.preferredKeyID) { + Text("Automatic").tag(UUID?.none) + ForEach(keys) { Text($0.label).tag(Optional($0.id)) } + } + } + } + if let problem = model.problem(for: value) { Text(problem).font(.caption).foregroundStyle(.secondary) } + if value.requiresPassphrase { Button("Unlock Key") { unlockID = value.id } } + if value.needsAuthorization != nil { Button("Choose Referenced File") { authorizing = true } } + if !model.warnings(for: value).isEmpty { + ForEach(model.warnings(for: value), id: \.self) { Text($0).font(.caption) } + Toggle("Accept Import Warnings", isOn: item.acceptedWarnings) + } + } + } +} + +struct MobileSSHImportScreen: View { + @State private var model: SSHImportModel + init(app: MobileAppModel) { _model = State(initialValue: app.makeSSHImportModel()) } + var body: some View { MobileSSHImportView(model: model) } +} diff --git a/TFI/Features/MobileSettingsView.swift b/TFI/Features/MobileSettingsView.swift new file mode 100644 index 00000000..811127ce --- /dev/null +++ b/TFI/Features/MobileSettingsView.swift @@ -0,0 +1,31 @@ +import SwiftUI + +struct MobileSettingsView: View { + let app: MobileAppModel + @AppStorage("mobile.appearance") private var appearance = "system" + + var body: some View { + Form { + Section { Button("Termind Pro") { app.pro.presentPaywall() } } + Section { NavigationLink("AI") { MobileAISettingsView(ai: app.ai) } } + Section("App") { + Picker("Appearance", selection: $appearance) { + Text("System").tag("system") + Text("Light").tag("light") + Text("Dark").tag("dark") + } + LabeledContent( + "Version", + value: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0") + } + Section("Live Activities") { + Button("Show Workspace Live Activity") { app.liveActivities.start(app.workspaces) } + Text( + "One Live Activity summarizes all workspaces. Tap it to open the workspace list. Live Activities do not keep SSH running in the background." + ) + .font(.caption).foregroundStyle(.secondary) + if let error = app.liveActivities.error { Text(error).foregroundStyle(.red) } + } + }.navigationTitle("Settings") + } +} diff --git a/TFI/Features/MobileSnippetsView.swift b/TFI/Features/MobileSnippetsView.swift new file mode 100644 index 00000000..d3c76a80 --- /dev/null +++ b/TFI/Features/MobileSnippetsView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import TermindDomain + +struct MobileSnippetsView: View { + let app: MobileAppModel + @State private var editing: Snippet? + @State private var query = "" + + var body: some View { + List(app.snippets.searchSnippets(query)) { snippet in + Button { + editing = snippet + } label: { + VStack(alignment: .leading) { + Text(snippet.title) + Text(snippet.body).font(.caption.monospaced()).lineLimit(2).foregroundStyle(.secondary) + } + } + } + .searchable(text: $query) + .navigationTitle("Snippets") + .toolbar { Button("New Snippet", systemImage: "plus") { editing = Snippet(title: "", body: "") } } + .sheet(item: $editing) { MobileSnippetEditor(app: app, snippet: $0) } + } +} + +private struct MobileSnippetEditor: View { + let app: MobileAppModel + let original: Snippet + @State private var draft: Snippet + + init(app: MobileAppModel, snippet: Snippet) { + self.app = app + original = snippet + _draft = State(initialValue: snippet) + } + + var body: some View { + MobileEditorSheet( + title: String(localized: "Snippet"), isDirty: draft != original, + canSave: !draft.title.isEmpty && !draft.body.isEmpty, + save: { + app.snippets.upsert(draft) + if let error = app.library.storageError { throw MobileEditorError.message(error) } + }, + content: { + TextField("Title", text: $draft.title) + TextEditor(text: $draft.body).font(.body.monospaced()).frame(minHeight: 220) + .textInputAutocapitalization(.never).autocorrectionDisabled() + }) + } +} diff --git a/TFI/Features/MobileTerminalKeyboard.swift b/TFI/Features/MobileTerminalKeyboard.swift new file mode 100644 index 00000000..9f6cf05d --- /dev/null +++ b/TFI/Features/MobileTerminalKeyboard.swift @@ -0,0 +1,61 @@ +import GhosttyTerminal +import SwiftUI + +struct MobileTerminalKeyboard: View { + @ObservedObject var state: TerminalViewState + @State private var control: TerminalPublicStickyActivation = .inactive + @State private var alt: TerminalPublicStickyActivation = .inactive + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + key("Esc", .escape) + key("Tab", .tab) + modifier("Ctrl", .ctrl, activation: control) + modifier("Alt", .alt, activation: alt) + key("←", .arrowLeft) + key("↓", .arrowDown) + key("↑", .arrowUp) + key("→", .arrowRight) + key("Home", .home) + key("End", .end) + key("PgUp", .pageUp) + key("PgDn", .pageDown) + Button { + state.attachedPlatformView?.resignFirstResponder() + } label: { + Image(systemName: "keyboard.chevron.compact.down") + }.accessibilityLabel("Hide Keyboard") + } + .buttonStyle(.bordered) + .font(.system(.callout, design: .monospaced)) + .padding(8) + }.background(.bar) + .onChange(of: state.isFocused, initial: true) { _, _ in + state.attachedPlatformView?.setStickyModifierChangeHandler { + control = state.attachedPlatformView?.stickyActivation(for: .ctrl) ?? .inactive + alt = state.attachedPlatformView?.stickyActivation(for: .alt) ?? .inactive + } + } + .onDisappear { state.attachedPlatformView?.setStickyModifierChangeHandler(nil) } + } + + private func modifier( + _ title: String, _ modifier: TerminalPublicStickyModifier, + activation: TerminalPublicStickyActivation + ) -> some View { + Button { + state.attachedPlatformView?.toggleStickyModifier(modifier) + } label: { + Text(activation == .locked ? title + " •" : title) + } + .tint(activation == .inactive ? .secondary : .accentColor) + .accessibilityValue(activation.rawValue) + } + + private func key(_ title: String, _ key: TerminalKey) -> some View { + Button(title) { + _ = state.attachedPlatformView?.sendKey(TerminalKeyPress(key)) + } + } +} diff --git a/TFI/Features/MobileTransfersView.swift b/TFI/Features/MobileTransfersView.swift new file mode 100644 index 00000000..6ac4a63b --- /dev/null +++ b/TFI/Features/MobileTransfersView.swift @@ -0,0 +1,31 @@ +import SwiftUI +import TermindDomain +import TermindRemoteInfrastructure + +struct MobileTransfersView: View { + let transfers: SFTPTransferCoordinator + + var body: some View { + List(transfers.sftpTransfers) { transfer in + VStack(alignment: .leading, spacing: 8) { + Label(transfer.fileName, systemImage: transfer.direction.symbol).font(.headline) + Text(transfer.remotePath).font(.caption.monospaced()).lineLimit(2).foregroundStyle(.secondary) + Text(transfer.state.label).font(.caption) + if transfer.state == .queued || transfer.state == .running { + ProgressView(value: transfer.byteCount > 0 ? transfer.progress : nil) + Button("Cancel") { transfers.cancel(transfer.id) } + } + if transfer.direction == .download && transfer.state == .completed { + ShareLink("Save or Share", item: URL(fileURLWithPath: transfer.localPath)) + } + } + } + .overlay { + if transfers.sftpTransfers.isEmpty { + ContentUnavailableView("No Transfers", systemImage: "arrow.up.arrow.down") + } + } + .navigationTitle("Transfers") + .toolbar { Button("Clear Finished") { transfers.clearFinished() } } + } +} diff --git a/TFI/Features/MobileVaultView.swift b/TFI/Features/MobileVaultView.swift new file mode 100644 index 00000000..c322008b --- /dev/null +++ b/TFI/Features/MobileVaultView.swift @@ -0,0 +1,97 @@ +import SwiftUI +import TermindDomain +import TermindPresentation + +import struct TermindDomain.Host + +struct MobileVaultView: View { + let app: MobileAppModel + let open: (UUID, MobileWorkspaceDescriptor.Kind) -> Void + @State private var query = "" + @State private var editingHost: Host? + @State private var deletingHost: Host? + @State private var groupName = "" + @State private var addingGroup = false + @State private var importingSSH = false + + var body: some View { + List { + Section { + NavigationLink("Keychain") { MobileCredentialsView(app: app) } + NavigationLink("Snippets") { MobileSnippetsView(app: app) } + NavigationLink("Known Hosts") { MobileKnownHostsView(app: app) } + NavigationLink("Port Forwarding") { MobileForwardingsView(app: app) } + NavigationLink("Transfers") { MobileTransfersView(transfers: app.transfers) } + } + if app.library.hosts.isEmpty { + ContentUnavailableView( + "No Hosts", systemImage: "server.rack", + description: Text("Add a host to connect with SSH or SFTP.")) + } + ForEach(app.library.groups) { group in + Section(group.name) { + ForEach(app.library.searchHosts(query).filter { $0.groupID == group.id }) { row($0) } + } + } + Section("Hosts") { + ForEach(app.library.searchHosts(query).filter { $0.groupID == nil }) { row($0) } + } + } + .searchable(text: $query) + .navigationTitle("Vault") + .toolbar { + ToolbarItem(placement: .primaryAction) { + Menu { + Button("New Host", systemImage: "server.rack") { editingHost = Host(name: "", host: "") } + Button("New Group", systemImage: "folder.badge.plus") { addingGroup = true } + Button("Import SSH Files", systemImage: "square.and.arrow.down") { importingSSH = true } + } label: { + Label("Add", systemImage: "plus") + } + } + } + .sheet(isPresented: $importingSSH) { MobileSSHImportScreen(app: app) } + .sheet(item: $editingHost) { MobileHostEditor(library: app.library, host: $0) } + .sheet(isPresented: $addingGroup) { + MobileEditorSheet( + title: String(localized: "New Group"), isDirty: !groupName.isEmpty, + canSave: !groupName.isEmpty, compact: true, + save: { + app.library.upsert(HostGroup(name: groupName)) + if let error = app.library.storageError { throw MobileEditorError.message(error) } + groupName = "" + } + ) { TextField("Name", text: $groupName) } + } + .confirmationDialog( + "Delete Host?", isPresented: Binding(get: { deletingHost != nil }, set: { if !$0 { deletingHost = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let host = deletingHost { app.deleteHost(host.id) } + deletingHost = nil + } + } message: { + Text("Open connections and references to this host will be removed.") + } + } + + private func row(_ host: Host) -> some View { + Menu { + Button("SSH", systemImage: "terminal") { open(host.id, .ssh) } + Button("SFTP", systemImage: "folder") { open(host.id, .sftp) } + Button("Edit", systemImage: "pencil") { editingHost = host } + Button("Delete", systemImage: "trash", role: .destructive) { deletingHost = host } + } label: { + HStack { + HostSystemIcon(asset: LinuxDistributionIconCatalog.entry(for: host.detectedDistribution)?.asset) + VStack(alignment: .leading) { + Text(host.name).foregroundStyle(.primary) + Text(app.library.loginAddress(for: host)).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "ellipsis").foregroundStyle(.secondary) + } + } + } +} diff --git a/TFI/Features/MobileWorkspaceView.swift b/TFI/Features/MobileWorkspaceView.swift new file mode 100644 index 00000000..eb4d657e --- /dev/null +++ b/TFI/Features/MobileWorkspaceView.swift @@ -0,0 +1,165 @@ +import GhosttyTerminal +import SwiftUI +import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure +import UniformTypeIdentifiers + +struct MobileWorkspaceView: View { + @Bindable var workspace: MobileWorkspace + @State private var importing = false + @State private var directoryName = "" + @State private var creatingDirectory = false + @State private var deleting: RemoteFile? + @State private var renaming: RemoteFile? + @State private var editingPermissions: RemoteFile? + @State private var showingTransfers = false + + var body: some View { + VStack(spacing: 0) { + if workspace.descriptor.kind == .ssh { + TerminalSurfaceView(context: workspace.terminal) + .id(workspace.id) + .onTapGesture { workspace.terminal.requestFocus() } + MobileTerminalKeyboard(state: workspace.terminal) + } else { + List { + Button("Parent Directory", systemImage: "arrow.up") { + Task { await workspace.openDirectory(RemotePath.parent(of: workspace.path)) } + } + ForEach(workspace.files) { file in + Menu { + if file.isDirectory { + Button("Open", systemImage: "folder") { + Task { await workspace.openDirectory(file.path) } + } + } else { + Button("Download", systemImage: "square.and.arrow.down") { + workspace.download(file) + showingTransfers = true + } + } + Button("Rename", systemImage: "pencil") { renaming = file } + Button("Edit Permissions", systemImage: "lock.shield") { editingPermissions = file } + Button("Delete", systemImage: "trash", role: .destructive) { deleting = file } + } label: { + Label(file.name, systemImage: file.kind.symbol) + } + .disabled(!workspace.connected) + .swipeActions { Button("Delete", role: .destructive) { deleting = file } } + } + } + .refreshable { await workspace.openDirectory(workspace.path) } + } + HStack { + Circle().fill(workspace.connected ? Color.green : Color.secondary).frame(width: 7, height: 7) + Text(workspace.status).font(.caption) + Spacer() + if workspace.busy { ProgressView().controlSize(.small) } + Button(workspace.connected ? String(localized: "Disconnect") : String(localized: "Connect")) { + if workspace.connected { workspace.disconnect() } else { workspace.connect() } + }.disabled(workspace.busy) + }.padding(12).background(.bar) + } + .navigationTitle(workspace.descriptor.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + if workspace.descriptor.kind == .sftp { + Button("Transfers", systemImage: "arrow.up.arrow.down") { showingTransfers = true } + Menu { + Button("Upload", systemImage: "square.and.arrow.up") { importing = true } + Button("New Folder", systemImage: "folder.badge.plus") { creatingDirectory = true } + } label: { + Image(systemName: "plus") + }.disabled(!workspace.connected) + } + } + .sheet(item: $renaming) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .rename) } + .sheet(item: $editingPermissions) { MobileRemoteFileEditor(workspace: workspace, file: $0, mode: .permissions) } + .sheet(isPresented: $showingTransfers) { + NavigationStack { + if let transfers = workspace.transfers { MobileTransfersView(transfers: transfers) } + }.presentationDetents([.medium, .large]) + } + .fileImporter(isPresented: $importing, allowedContentTypes: [.data]) { result in + switch result { + case .success(let url): + Task { + await workspace.upload(url) + showingTransfers = true + } + case .failure(let error): workspace.error = error.localizedDescription + } + } + .alert("New Folder", isPresented: $creatingDirectory) { + TextField("Name", text: $directoryName) + Button("Create") { + Task { + await workspace.createDirectory(directoryName) + directoryName = "" + } + } + Button("Cancel", role: .cancel) {} + } + .confirmationDialog( + "Delete File?", isPresented: Binding(get: { deleting != nil }, set: { if !$0 { deleting = nil } }), + titleVisibility: .visible + ) { + Button("Delete", role: .destructive) { + if let file = deleting { Task { await workspace.delete(file) } } + deleting = nil + } + } + .alert( + "Trust Host Key?", + isPresented: Binding( + get: { workspace.pendingHostKey != nil }, set: { if !$0 { workspace.pendingHostKey = nil } }) + ) { + Button("Trust") { workspace.trustHostKey() } + Button("Cancel", role: .cancel) { workspace.pendingHostKey = nil } + } message: { + Text( + workspace.pendingHostKey.map { + "\($0.hostname):\($0.port)\n\($0.algorithm.rawValue)\n\($0.fingerprint)" + } ?? "") + } + .sheet(isPresented: $workspace.authenticationRequired) { + NavigationStack { + Form { + TextField("Username", text: $workspace.username).textInputAutocapitalization(.never) + .autocorrectionDisabled() + Text(workspace.authenticationHostName).font(.headline) + SecureField("Password", text: $workspace.password) + SecureField("Passphrase", text: $workspace.passphrase) + } + .navigationTitle("Authentication") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { + workspace.cancelAuthentication() + } + } + ToolbarItem(placement: .confirmationAction) { + Button("Connect") { + workspace.submitAuthentication() + } + } + } + }.presentationDetents([.medium, .large]) + .onDisappear { + workspace.password = "" + workspace.passphrase = "" + } + } + .alert( + "Connection Error", + isPresented: Binding( + get: { workspace.error != nil && !workspace.authenticationRequired }, + set: { if !$0 { workspace.error = nil } }) + ) { + Button("OK", role: .cancel) { workspace.error = nil } + } message: { + Text(workspace.error ?? "") + } + } +} diff --git a/TFI/Info.plist b/TFI/Info.plist new file mode 100644 index 00000000..61c2c962 --- /dev/null +++ b/TFI/Info.plist @@ -0,0 +1,11 @@ + + + +NSFaceIDUsageDescriptionAuthenticate to use your SSH keys. +TermindKeychainAccessGroup$(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) +NSSupportsLiveActivities +CFBundleURLTypes +CFBundleURLNamecom.akinokaede.termind.workspace +CFBundleURLSchemestermind + + diff --git a/TFI/Platform/MobileBackgroundController.swift b/TFI/Platform/MobileBackgroundController.swift new file mode 100644 index 00000000..4e3e38a2 --- /dev/null +++ b/TFI/Platform/MobileBackgroundController.swift @@ -0,0 +1,23 @@ +import UIKit + +@MainActor +final class MobileBackgroundController { + private var task: UIBackgroundTaskIdentifier = .invalid + + func begin(expiration: @escaping @MainActor () -> Void) { + finish() + task = UIApplication.shared.beginBackgroundTask(withName: "Finish remote session work") { [weak self] in + Task { @MainActor in + expiration() + self?.finish() + } + } + if task == .invalid { expiration() } + } + + func finish() { + guard task != .invalid else { return } + UIApplication.shared.endBackgroundTask(task) + task = .invalid + } +} diff --git a/TFI/Platform/MobileLiveActivityController.swift b/TFI/Platform/MobileLiveActivityController.swift new file mode 100644 index 00000000..7b3e990e --- /dev/null +++ b/TFI/Platform/MobileLiveActivityController.swift @@ -0,0 +1,72 @@ +@preconcurrency import ActivityKit +import Foundation +import Observation +import TermindPresentation +import UIKit + +@MainActor +@Observable +final class MobileLiveActivityController { + private(set) var error: String? + private var activity: Activity? + private var updates: Task? + private let enabled: Bool + private var focusedWorkspaceID: UUID? + + init(enabled: Bool = true) { + self.enabled = enabled + if enabled { + let restored = Activity.activities + activity = restored.first + updates = Task { + for duplicate in restored.dropFirst() { + await duplicate.end(nil, dismissalPolicy: .immediate) + } + } + } + } + + func focus(_ id: UUID, in workspaces: [MobileWorkspace]) { + guard workspaces.contains(where: { $0.id == id }) else { return } + focusedWorkspaceID = id + update(workspaces) + } + + /// All starts, updates and ends share a serial queue, including close-last/open-new races. + func start(_ workspaces: [MobileWorkspace]) { schedule(workspaces, mayStart: true) } + func update(_ workspaces: [MobileWorkspace]) { schedule(workspaces, mayStart: false) } + + private func schedule(_ workspaces: [MobileWorkspace], mayStart: Bool) { + guard enabled else { return } + let state = WorkspaceActivityAttributes.ContentState.snapshot( + workspaces.map { + .init( + id: $0.id, title: $0.descriptor.title, kind: $0.descriptor.kind.rawValue, connected: $0.connected, + iconAsset: LinuxDistributionIconCatalog.entry(for: $0.detectedDistribution)?.asset) + }, focusedID: focusedWorkspaceID) + let previous = updates + updates = Task { [weak self] in + await previous?.value + guard let self else { return } + guard state.workspaceCount > 0 else { + if let activity { await activity.end(nil, dismissalPolicy: .immediate) } + activity = nil + return + } + let content = ActivityContent(state: state, staleDate: Date.now.addingTimeInterval(60)) + if let activity, activity.activityState == .active || activity.activityState == .stale { + await activity.update(content) + return + } + // Updates do not restart a dismissed activity. Requests require the foreground. + guard mayStart, UIApplication.shared.applicationState == .active, + ActivityAuthorizationInfo().areActivitiesEnabled + else { return } + do { + activity = try Activity.request( + attributes: WorkspaceActivityAttributes(), content: content, pushType: nil) + error = nil + } catch { self.error = error.localizedDescription } + } + } +} diff --git a/TFI/Platform/MobileTerminalSurface.swift b/TFI/Platform/MobileTerminalSurface.swift new file mode 100644 index 00000000..cd7f7094 --- /dev/null +++ b/TFI/Platform/MobileTerminalSurface.swift @@ -0,0 +1,8 @@ +import GhosttyTerminal +import UIKit + +/// A workspace owns its native surface even while SwiftUI navigates away from it. +@MainActor +final class MobileTerminalSurface { + let view = TerminalView(frame: .zero) +} diff --git a/TFI/Termind.entitlements b/TFI/Termind.entitlements new file mode 100644 index 00000000..f4139b14 --- /dev/null +++ b/TFI/Termind.entitlements @@ -0,0 +1,8 @@ + + + + + keychain-access-groups + $(AppIdentifierPrefix)$(PRODUCT_BUNDLE_IDENTIFIER) + + diff --git a/TFIActivityShared/WorkspaceActivityAttributes.swift b/TFIActivityShared/WorkspaceActivityAttributes.swift new file mode 100644 index 00000000..4f0226f1 --- /dev/null +++ b/TFIActivityShared/WorkspaceActivityAttributes.swift @@ -0,0 +1,63 @@ +import ActivityKit +import Foundation + +/// One activity summarizes all mobile workspaces. Never contains credentials or terminal output. +nonisolated struct WorkspaceActivityAttributes: ActivityAttributes { + struct Session: Codable, Hashable, Identifiable { + var id: UUID + var title: String + var kind: String + var connected: Bool + var iconAsset: String? = nil + + var symbol: String { "server.rack" } + var destinationURL: URL { WorkspaceActivityAttributes.destinationURL(for: id) } + } + + struct ContentState: Codable, Hashable { + var workspaceCount: Int + var connectedCount: Int + /// Bounded to fit ActivityKit's payload and the lock-screen layout. + var sessions: [Session] = [] + var focusedWorkspaceID: UUID? + + static func snapshot(_ workspaces: [Session], focusedID: UUID?) -> Self { + let focused = workspaces.first(where: { $0.id == focusedID }) ?? workspaces.last + let ordered = + focused.map { selected in [selected] + workspaces.filter { $0.id != selected.id } } ?? workspaces + let visible = ordered.prefix(6).map { session in + var session = session + session.title = String(decoding: String(session.title.prefix(32)).utf8.prefix(96), as: UTF8.self) + return session + } + return Self( + workspaceCount: workspaces.count, connectedCount: workspaces.filter(\.connected).count, + sessions: visible, focusedWorkspaceID: focused?.id) + } + + var destinationURL: URL { + focusedWorkspaceID.map(WorkspaceActivityAttributes.destinationURL(for:)) + ?? WorkspaceActivityAttributes.destinationURL + } + } + + var scope = "workspaces" + + static var destinationURL: URL { URL(string: "termind://workspaces")! } + + static func destinationURL(for workspaceID: UUID) -> URL { + URL(string: "termind://workspace/\(workspaceID.uuidString)")! + } + + static func accepts(_ url: URL) -> Bool { + url.scheme == "termind" && url.host == "workspaces" + && (url.path.isEmpty || url.path == "/") && url.query == nil && url.fragment == nil + } + + static func workspaceID(from url: URL) -> UUID? { + guard url.scheme == "termind", url.host == "workspace", + url.query == nil, url.fragment == nil, url.pathComponents.count == 2 + else { return nil } + return UUID(uuidString: url.lastPathComponent) + } +} diff --git a/TFITests/MobileAppTests.swift b/TFITests/MobileAppTests.swift index 48a248ae..00a5d3e0 100644 --- a/TFITests/MobileAppTests.swift +++ b/TFITests/MobileAppTests.swift @@ -1,7 +1,9 @@ +import Foundation import SwiftData import TermindDomain import TermindInfrastructure import TermindPersistence +import TermindPresentation import Testing @testable import TermindIOS @@ -9,10 +11,10 @@ import Testing @Suite @MainActor struct MobileAppTests { @Test func startupUsesTheSharedSchemaAndEmptyRepository() throws { - let app = try MobileAppModel() + let app = try MobileAppModel(inMemory: true) #expect(try app.hosts.snapshot().value == .empty) #expect(Set(app.container.schema.entities.map(\.name)) == Set(TermindPersistence.schema.entities.map(\.name))) - #expect(MobileDestination.allCases == [.hosts, .assistant, .settings]) + #expect(MobileTab.allCases == [.home, .metrics, .assistant, .settings]) } @Test func scenesCanUseTheSameAppRepository() throws { @@ -23,3 +25,110 @@ struct MobileAppTests { #expect(try app.hosts.snapshot().value.hosts == [host]) } } + +@Suite @MainActor +struct MobileWorkspaceTests { + @Test func descriptorRoundTripContainsOnlyRestorationMetadata() throws { + let descriptor = MobileWorkspaceDescriptor(hostID: UUID(), title: "Production", kind: .ssh) + let data = try JSONEncoder().encode(descriptor) + #expect(try JSONDecoder().decode(MobileWorkspaceDescriptor.self, from: data) == descriptor) + let keys = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(Set(keys.keys) == ["id", "hostID", "title", "kind"]) + } + + @Test func liveActivitiesUseStableDistinctWorkspaceDestinations() { + let first = MobileWorkspaceDescriptor(hostID: UUID(), title: "Server", kind: .ssh) + var renamed = first + renamed.title = "Renamed" + let second = MobileWorkspaceDescriptor(hostID: first.hostID, title: "Server", kind: .sftp) + #expect(first.destinationURL == renamed.destinationURL) + #expect(first.destinationURL != second.destinationURL) + } + + @Test func hostEditsPersistThroughTheSharedVaultStore() throws { + let app = try MobileAppModel(inMemory: true) + let host = TermindDomain.Host(name: "Test", host: "example.invalid") + app.library.upsert(host) + #expect(app.library.storageError == nil) + let store = HostPersistence(context: ModelContext(app.container)) + #expect(try store.load().hosts == app.library.hosts) + #expect(try store.load().hosts.first?.id == host.id) + } + + @Test func freeMetricsLimitCountsAllSelectionsAndDeduplicates() { + let metrics = MobileMetricsModel(defaults: nil) + let id = UUID() + #expect(metrics.add(id, pro: false)) + #expect(metrics.add(id, pro: false)) + #expect(metrics.add(UUID(), pro: false)) + #expect(metrics.add(UUID(), pro: false)) + #expect(!metrics.add(UUID(), pro: false)) + #expect(metrics.add(UUID(), pro: true)) + #expect(metrics.selections.count == 4) + } +} + +@Suite struct WorkspaceActivityRoutingTests { + @Test func activityBoundsItsPayloadAndKeepsTheFocusedSessionVisible() throws { + let sessions = (0..<12).map { index in + WorkspaceActivityAttributes.Session( + id: UUID(), title: String(repeating: "服务器🖥️", count: 100), + kind: "ssh", connected: index < 3) + } + let focused = try #require(sessions.first?.id) + let state = WorkspaceActivityAttributes.ContentState.snapshot(sessions, focusedID: focused) + #expect(state.workspaceCount == 12) + #expect(state.connectedCount == 3) + #expect(state.sessions.count == 6) + #expect(state.sessions.first?.id == focused) + #expect(WorkspaceActivityAttributes.workspaceID(from: state.destinationURL) == focused) + #expect(try JSONEncoder().encode(state).count < 3500) + } + + @Test func aggregateActivityOpensOnlyTheWorkspaceList() throws { + #expect(WorkspaceActivityAttributes.accepts(WorkspaceActivityAttributes.destinationURL)) + for value in ["https://workspaces", "termind://workspaces/extra", "termind://workspaces?command=ls"] { + #expect(!WorkspaceActivityAttributes.accepts(try #require(URL(string: value)))) + } + } + + @Test func acceptsOnlyWorkspaceLinks() throws { + let id = UUID() + #expect( + WorkspaceActivityAttributes.workspaceID(from: try #require(URL(string: "termind://workspace/\(id)"))) == id) + for value in [ + "https://workspace/\(id)", "termind://workspace/not-an-id", "termind://workspace/\(id)/extra", + "termind://workspace/\(id)?command=ls", + ] { + #expect(WorkspaceActivityAttributes.workspaceID(from: try #require(URL(string: value))) == nil) + } + } +} + +@Suite struct WorkspaceHostIconTests { + @Test func usesTheSameDistributionMatchAsTheHostList() throws { + let catalog = try JSONDecoder().decode( + LinuxDistributionIconCatalog.self, + from: Data( + #"{"entries":[{"title":"Ubuntu","asset":"LinuxDistro-ubuntu","ids":["ubuntu"],"hex":"E95420"},{"title":"Debian","asset":"LinuxDistro-debian","ids":["debian"],"hex":"A81D33"}]}"# + .utf8)) + let ubuntu = try #require(DetectedDistribution(id: "ubuntu", likeIDs: ["debian"])) + #expect(catalog.entry(matching: ubuntu)?.asset == "LinuxDistro-ubuntu") + let derivative = try #require(DetectedDistribution(id: "custom", likeIDs: ["debian"])) + #expect(catalog.entry(matching: derivative)?.asset == "LinuxDistro-debian") + #expect(catalog.entry(matching: try #require(DetectedDistribution(id: "unknown"))) == nil) + } + + @Test func activityPreservesSystemArtworkAndSessionDestination() throws { + let id = UUID() + let session = WorkspaceActivityAttributes.Session( + id: id, title: "Production", kind: "ssh", + connected: true, iconAsset: "LinuxDistro-ubuntu") + let state = WorkspaceActivityAttributes.ContentState.snapshot([session], focusedID: id) + let restored = try JSONDecoder().decode( + WorkspaceActivityAttributes.ContentState.self, + from: JSONEncoder().encode(state)) + #expect(restored.sessions.first?.iconAsset == "LinuxDistro-ubuntu") + #expect(WorkspaceActivityAttributes.workspaceID(from: restored.destinationURL) == id) + } +} diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift new file mode 100644 index 00000000..267302ae --- /dev/null +++ b/TFITests/MobileVaultFeatureTests.swift @@ -0,0 +1,126 @@ +import Foundation +import SwiftData +import TermindApplication +import TermindDomain +import TermindPersistence +import Testing + +@testable import TermindIOS + +@Suite @MainActor +struct MobileVaultFeatureTests { + @Test func encryptedKeyGenerationImportAndMetadataEditingKeepTheSecretOutOfRecords() async throws { + let app = try MobileAppModel(inMemory: true) + let generator = MobileKeyEditorModel(mode: .generate, credentials: app.credentials) + #expect(!generator.isDirty) + generator.label = "Generated" + #expect(generator.isDirty) + generator.passphrase = "test-only-passphrase" + try await generator.save() + let key = try #require(app.credentials.keys.first) + #expect(key.hasPassphrase) + #expect(generator.privateKey.isEmpty && generator.passphrase.isEmpty) + let savedCredential = try app.credentials.keyCredential(for: key) + guard case .software(let secret) = try #require(savedCredential) else { + Issue.record("Expected a software key") + return + } + let record = String(decoding: try JSONEncoder().encode(key), as: UTF8.self) + #expect(!record.contains("PRIVATE KEY")) + #expect(!record.contains("test-only-passphrase")) + + let invalid = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) + invalid.label = "Invalid" + invalid.privateKey = secret.privateKey + invalid.passphrase = "wrong" + await #expect(throws: (any Error).self) { try await invalid.save() } + #expect(app.credentials.keys.count == 1) + + let importer = MobileKeyEditorModel(mode: .importKey, credentials: app.credentials) + importer.label = "Imported" + importer.privateKey = secret.privateKey + importer.passphrase = "test-only-passphrase" + try await importer.save() + #expect(app.credentials.keys.count == 2) + #expect(app.credentials.keys.allSatisfy { $0.fingerprint == key.fingerprint }) + + let editor = MobileKeyEditorModel(mode: .edit, credentials: app.credentials, key: key) + #expect(!editor.isDirty) + editor.label = "Renamed" + try await editor.save() + let edited = try #require(app.credentials.key(key.id)) + #expect(edited.label == "Renamed") + #expect(edited.credentialRef == key.credentialRef) + #expect(try app.credentials.keyCredential(for: edited) == .software(secret)) + } + + @Test func importRequiresAnExplicitUsernameWhenTheConfigurationOmitsIt() async throws { + let app = try MobileAppModel(inMemory: true) + let item = SSHImportItem( + category: .hosts, name: "Remote", source: "config", + host: SSHImportedHost(alias: "remote", hostname: "example.invalid", username: "")) + let model = SSHImportModel(library: app.library, reader: MobileImportFixture(items: [item])) + await model.scan(URL(fileURLWithPath: "/fixture/.ssh"), knownHostsOnly: false) + #expect(!model.canImport) + model.items[0].host?.username = "deploy" + #expect(model.canImport) + await model.commit() + #expect(model.completed) + let host = try #require(app.library.hosts.first) + #expect(app.library.identity(for: host)?.username == "deploy") + } + + @Test func forwardingEditsValidateAndStopOnlyTheChangedRule() throws { + let app = try MobileAppModel(inMemory: true) + let host = TermindDomain.Host(name: "Remote", host: "example.invalid") + app.library.upsert(host) + var rule = PortForwardingRule(hostID: host.id) + try app.forwardingLibrary.save(rule) + #expect(!app.forwardings.isEnabled(rule.id)) + var stopped: [UUID] = [] + app.library.willDeletePortForwardings = { stopped.append(contentsOf: $0) } + rule.bindPort = 9000 + try app.forwardingLibrary.save(rule) + #expect(stopped == [rule.id]) + var invalid = rule + invalid.bindPort = 0 + #expect(throws: (any Error).self) { try app.forwardingLibrary.save(invalid) } + #expect(app.forwardingLibrary.portForwarding(rule.id)?.bindPort == 9000) + try app.forwardingLibrary.delete(rule.id) + #expect(app.forwardingLibrary.rules.isEmpty) + } + + @Test func deletingAHostCompletesDurableCleanup() async throws { + let app = try MobileAppModel(inMemory: true) + defer { app.outbox.stop() } + let host = TermindDomain.Host(name: "Remote", host: "example.invalid") + app.library.upsert(host) + let dependent = TermindDomain.Host(name: "Dependent", host: "dependent.invalid", jumpHostID: host.id) + app.library.upsert(dependent) + app.deleteHost(host.id) + #expect(app.library.host(host.id) == nil) + var finished = false + for _ in 0..<100 { + let context = ModelContext(app.container) + let jobs = try context.fetch(FetchDescriptor()) + if jobs.count == 5 && jobs.allSatisfy({ $0.status == OutboxJobStatus.succeeded.rawValue }) { + finished = true + break + } + try await Task.sleep(for: .milliseconds(20)) + } + #expect(finished) + let saved = try HostPersistence(context: ModelContext(app.container)).load() + #expect(!saved.hosts.contains(where: { $0.id == host.id })) + #expect(saved.hosts.first(where: { $0.id == dependent.id })?.jumpHostID == nil) + } +} + +private actor MobileImportFixture: SSHImportReading { + let items: [SSHImportItem] + init(items: [SSHImportItem]) { self.items = items } + func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) -> [SSHImportItem] { items } + func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef { throw CocoaError(.coderInvalidValue) } + func credentials(for ids: Set) -> [UUID: StoredSSHKeyCredential] { [:] } + func discard() {} +} diff --git a/TFIWidgets/Info.plist b/TFIWidgets/Info.plist new file mode 100644 index 00000000..00214797 --- /dev/null +++ b/TFIWidgets/Info.plist @@ -0,0 +1,5 @@ + + + +NSExtensionNSExtensionPointIdentifiercom.apple.widgetkit-extension + diff --git a/TFIWidgets/WorkspaceLiveActivity.swift b/TFIWidgets/WorkspaceLiveActivity.swift new file mode 100644 index 00000000..4e0c3841 --- /dev/null +++ b/TFIWidgets/WorkspaceLiveActivity.swift @@ -0,0 +1,79 @@ +import ActivityKit +import SwiftUI +import TermindPresentation +import WidgetKit + +@main +struct TermindWidgets: WidgetBundle { + var body: some Widget { WorkspaceLiveActivity() } +} + +struct WorkspaceLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: WorkspaceActivityAttributes.self) { context in + VStack(alignment: .leading, spacing: 12) { + HStack { + Text("Termind Workspaces").font(.headline) + Spacer() + status(context) + } + sessions(context) + } + .padding() + .widgetURL(context.state.destinationURL) + } dynamicIsland: { context in + DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + HostSystemIcon(asset: context.state.sessions.first?.iconAsset, size: 24, showsBackground: false) + } + DynamicIslandExpandedRegion(.trailing) { + Text(context.state.workspaceCount, format: .number).monospacedDigit() + } + DynamicIslandExpandedRegion(.bottom) { + VStack(alignment: .leading, spacing: 8) { + sessions(context) + status(context) + } + } + } compactLeading: { + HostSystemIcon(asset: context.state.sessions.first?.iconAsset, size: 18, showsBackground: false) + } compactTrailing: { + Text(context.state.workspaceCount, format: .number).monospacedDigit() + } minimal: { + HostSystemIcon(asset: context.state.sessions.first?.iconAsset, size: 18, showsBackground: false) + } + .widgetURL(context.state.destinationURL) + } + } + + private func sessions(_ context: ActivityViewContext) -> some View { + HStack(spacing: 8) { + ForEach(context.state.sessions) { session in + Link(destination: session.destinationURL) { + VStack(spacing: 4) { + HostSystemIcon(asset: session.iconAsset) + Text(session.title).font(.caption2).lineLimit(1) + } + .frame(maxWidth: .infinity) + } + .accessibilityLabel(session.title) + .accessibilityHint("Open Workspace") + } + if context.state.workspaceCount > context.state.sessions.count { + Link(destination: WorkspaceActivityAttributes.destinationURL) { + Image(systemName: "ellipsis").frame(minWidth: 36, minHeight: 44) + }.accessibilityLabel("All Workspaces") + } + } + } + + private func status(_ context: ActivityViewContext) -> some View { + Group { + if context.isStale { + Text("Open to Check Connections") + } else { + Text("\(context.state.connectedCount) connected · \(context.state.workspaceCount) workspaces") + } + }.font(.caption).foregroundStyle(.secondary) + } +} diff --git a/TFM/App/ProAccessController.swift b/TFM/App/ProAccessController.swift index 1c1ea652..fe478384 100644 --- a/TFM/App/ProAccessController.swift +++ b/TFM/App/ProAccessController.swift @@ -1,405 +1,4 @@ -import Foundation -import StoreKit -import TermindDomain +import TermindInfrastructure -@MainActor -@Observable -final class ProAccessController { - static let productID = "com.akinokaede.termind.pro" - static let trialProductID = "com.akinokaede.termind.pro.trial" - static let reviewDemoCode = "TERMIND-REVIEW-2026" - - private static let reviewDemoDefaultsKey = "commerce.pro.review-demo-expired.v1" - - private let defaults: UserDefaults? - private let now: () -> Date - private var transactionUpdates: Task? - private var refreshTask: Task? - private var accessTask: Task? - private var metadataTask: Task? - private var productsTask: Task<[Product], any Error>? - private var trialExpirationTask: Task? - private var refreshGeneration: UInt64 = 0 - private var metadataError: ProAccessControllerError? - - private(set) var status: EntitlementStatus - private(set) var product: Product? - private(set) var trialProduct: Product? - private(set) var error: ProAccessControllerError? - private(set) var isProcessing = false - private(set) var pendingProductID: String? - private(set) var workspaceCompositionGrantedForLaunch: Bool - private(set) var isReviewDemoModeEnabled: Bool - var isPaywallPresented = false - var requestedFeature: ProFeature? - - var displayPrice: String? { product?.displayPrice } - var hasProAccess: Bool { status.hasProAccess } - var canPurchase: Bool { - status.canPurchase && !isProcessing && pendingProductID != Self.productID - } - var canStartTrial: Bool { - status == .free(trialAvailable: true) && !isProcessing - && pendingProductID != Self.trialProductID && product != nil - && trialProduct.map(Self.isFreeTrialProduct) == true - } - var message: String? { - if let error { return error.localizedDescription } - if pendingProductID != nil { return ProAccessControllerError.purchasePending.localizedDescription } - if status.canPurchase, let metadataError { - if metadataError == .trialUnavailable, status != .free(trialAvailable: true) { return nil } - return metadataError.localizedDescription - } - return nil - } - - static func live() -> ProAccessController { ProAccessController(defaults: .standard) } - - static func unrestrictedForTesting() -> ProAccessController { - testing(status: .unrestrictedForTesting) - } - - static func testing( - status: EntitlementStatus, - reviewDefaults: UserDefaults? = nil - ) -> ProAccessController { - let controller = ProAccessController(defaults: reviewDefaults, observeStore: false) - controller.status = status - controller.workspaceCompositionGrantedForLaunch = status.hasProAccess - return controller - } - - init( - defaults: UserDefaults?, - now: @escaping () -> Date = { .now }, - observeStore: Bool = true - ) { - self.defaults = defaults - self.now = now - isReviewDemoModeEnabled = defaults?.bool(forKey: Self.reviewDemoDefaultsKey) ?? false - status = .loading - workspaceCompositionGrantedForLaunch = false - guard observeStore else { return } - transactionUpdates = Task { [weak self] in - for await update in Transaction.updates { - guard !Task.isCancelled, let self else { return } - await self.handleTransactionUpdate(update) - } - } - refreshTask = Task { [weak self] in await self?.refresh() } - metadataTask = Task { [weak self] in await self?.reloadProducts() } - } - - isolated deinit { - transactionUpdates?.cancel() - refreshTask?.cancel() - accessTask?.cancel() - metadataTask?.cancel() - productsTask?.cancel() - trialExpirationTask?.cancel() - } - - func canUse(_ feature: ProFeature) -> Bool { - if feature == .workspaceComposition { return workspaceCompositionGrantedForLaunch } - return status.hasProAccess - } - - @discardableResult - func require(_ feature: ProFeature) -> Bool { - guard !canUse(feature) else { return true } - requestedFeature = feature - isPaywallPresented = true - return false - } - - func presentPaywall(for feature: ProFeature? = nil) { - requestedFeature = feature - isPaywallPresented = true - } - - @discardableResult - func activateReviewDemo(using code: String) -> Bool { - guard code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == Self.reviewDemoCode else { - return false - } - refreshGeneration &+= 1 - accessTask?.cancel() - accessTask = nil - isReviewDemoModeEnabled = true - defaults?.set(true, forKey: Self.reviewDemoDefaultsKey) - workspaceCompositionGrantedForLaunch = false - requestedFeature = nil - error = nil - apply(.trialExpired) - return true - } - - func deactivateReviewDemo() async { - disableReviewDemo() - await refresh() - } - - /// StoreKit's local transactions resolve access independently of network product metadata. - func refresh() async { await refresh(including: nil) } - - private func refresh(including transaction: Transaction?) async { - if status == .unrestrictedForTesting { return } - // Callers awaiting refresh must also wait for a newer, authoritative - // transaction refresh. Otherwise a timer/activation can supersede the - // purchase preflight while leaving the caller with stale eligibility. - if accessTask == nil || transaction != nil { - accessTask?.cancel() - refreshGeneration &+= 1 - accessTask = Task { [weak self] in - await self?.currentStatus(including: transaction) ?? .loading - } - } - let generation = refreshGeneration - guard let task = accessTask else { return } - let resolved = await task.value - guard generation == refreshGeneration else { - if accessTask != nil { await refresh() } - return - } - guard accessTask != nil else { return } - accessTask = nil - if case .purchased = resolved { - disableReviewDemo() - pendingProductID = nil - } else if case .trialActive = resolved, pendingProductID == Self.trialProductID { - pendingProductID = nil - } - apply(isReviewDemoModeEnabled ? .trialExpired : resolved) - } - - func startTrial() async { await purchaseProduct(Self.trialProductID) } - func purchase() async { await purchaseProduct(Self.productID) } - - private func purchaseProduct(_ id: String) async { - guard !isProcessing, pendingProductID != id, status.canPurchase else { return } - isProcessing = true - error = nil - defer { isProcessing = false } - await refresh() - guard status.canPurchase else { return } - if id == Self.trialProductID, status != .free(trialAvailable: true) { - if case .trialActive = status { - isPaywallPresented = false - } else { - error = status == .verificationFailed ? .purchaseVerificationFailed : .trialAlreadyUsed - } - return - } - do { - // Recheck metadata immediately before purchase, even if the UI cached it. - try await fetchProducts() - guard let product else { throw ProAccessControllerError.productUnavailable } - let selected: Product - if id == Self.trialProductID { - guard let trialProduct, Self.isFreeTrialProduct(trialProduct) else { - throw ProAccessControllerError.trialUnavailable - } - selected = trialProduct - } else { - selected = product - } - switch try await selected.purchase() { - case .success(let verification): - guard case .verified(let transaction) = verification, transaction.productID == id else { - error = .purchaseVerificationFailed - return - } - disableReviewDemo() - await refresh(including: transaction) - // Delivery/denial is resolved before acknowledging the transaction. - await transaction.finish() - if status.hasProAccess { - isPaywallPresented = false - } else if status == .trialExpired { - error = .trialAlreadyUsed - } - case .pending: - pendingProductID = id - case .userCancelled: - break - @unknown default: - error = .purchaseVerificationFailed - } - } catch StoreKitError.userCancelled { - // Cancellation is not an entitlement change or a failure to display. - } catch let purchaseError { - error = purchaseError as? ProAccessControllerError ?? .operationFailed(purchaseError.localizedDescription) - } - } - - func restorePurchases() async { - guard !isProcessing else { return } - isProcessing = true - error = nil - defer { isProcessing = false } - do { - try await AppStore.sync() - await refresh() - switch status { - case .purchased, .trialActive: - isPaywallPresented = false - case .trialExpired: - error = .trialAlreadyUsed - case .verificationFailed: - error = .purchaseVerificationFailed - default: - error = .purchaseNotFound - } - } catch { - self.error = .operationFailed(error.localizedDescription) - } - } - - func reloadProducts() async { - do { - try await fetchProducts() - } catch { - product = nil - trialProduct = nil - metadataError = .productMetadataUnavailable - } - } - - private func fetchProducts() async throws { - let task = productsTask ?? Task { try await Product.products(for: [Self.productID, Self.trialProductID]) } - productsTask = task - defer { productsTask = nil } - let loaded = try await task.value - product = loaded.first { $0.id == Self.productID && $0.type == .nonConsumable } - trialProduct = loaded.first { $0.id == Self.trialProductID } - if product == nil { - metadataError = .productUnavailable - } else if trialProduct.map(Self.isFreeTrialProduct) != true { - metadataError = .trialUnavailable - } else { - metadataError = nil - } - } - - static func isFreeTrialProduct(_ product: Product) -> Bool { - product.id == trialProductID && product.type == .nonConsumable && product.price == .zero - && !product.isFamilyShareable - } - - private func currentStatus(including update: Transaction?) async -> EntitlementStatus { - // The verified purchase/update can arrive before StoreKit's history cache. - if let update, update.productID == Self.productID, update.productType == .nonConsumable, - update.revocationDate == nil - { - return .purchased(update.ownershipType == .familyShared ? .familyShared : .purchased) - } - var verificationFailed = false - for await result in Transaction.currentEntitlements { - switch result { - case .verified(let transaction) - where transaction.productID == Self.productID && transaction.revocationDate == nil - && transaction.productType == .nonConsumable - && !(update?.productID == Self.productID && update?.revocationDate != nil - && update?.originalID == transaction.originalID): - return .purchased(transaction.ownershipType == .familyShared ? .familyShared : .purchased) - case .unverified(let transaction, _) where transaction.productID == Self.productID: - verificationFailed = true - default: break - } - } - var trials: [ProTrial] = [] - if let update, update.productID == Self.trialProductID { trials.append(Self.trialRecord(update)) } - var trialVerificationFailed = false - // Revoked products disappear from currentEntitlements but still count as claimed. - for await result in Transaction.all { - switch result { - case .verified(let transaction) where transaction.productID == Self.trialProductID: - trials.append(Self.trialRecord(transaction)) - case .unverified(let transaction, _) where transaction.productID == Self.trialProductID: - trialVerificationFailed = true - default: break - } - } - // An unverifiable trial-history entry must never become fresh eligibility. - if trialVerificationFailed { return .verificationFailed } - guard let trial = ProTrial.earliest(trials) else { - return verificationFailed ? .verificationFailed : .free(trialAvailable: true) - } - return trial.status(at: TrialClock(defaults: defaults).effectiveNow(for: trial, now: now())) - } - - private static func trialRecord(_ transaction: Transaction) -> ProTrial { - ProTrial( - originalID: transaction.originalID, purchasedAt: transaction.originalPurchaseDate, - isRevoked: transaction.revocationDate != nil || transaction.ownershipType != .purchased - || transaction.productType != .nonConsumable - ) - } - - func handleTransactionUpdate(_ update: VerificationResult) async { - guard [Self.productID, Self.trialProductID].contains(update.unsafePayloadValue.productID) else { return } - if case .verified(let transaction) = update { - error = nil - if pendingProductID == transaction.productID { pendingProductID = nil } - await refresh(including: transaction) - await transaction.finish() - } else { - error = .purchaseVerificationFailed - await refresh() - } - } - - private func apply(_ next: EntitlementStatus) { - status = next - if next.hasProAccess { workspaceCompositionGrantedForLaunch = true } - trialExpirationTask?.cancel() - if case .trialActive(let expiresAt) = next { - trialExpirationTask = Task { [weak self] in - // Recheck at least once a minute, including clock changes and sleep/wake. - let remaining = min(60, max(0, expiresAt.timeIntervalSince(self?.now() ?? .now))) - try? await Task.sleep(for: .seconds(remaining)) - guard !Task.isCancelled else { return } - await self?.refresh() - } - } - } - - private func disableReviewDemo() { - isReviewDemoModeEnabled = false - defaults?.removeObject(forKey: Self.reviewDemoDefaultsKey) - } -} - -nonisolated enum ProAccessControllerError: LocalizedError, Equatable, Sendable { - case productUnavailable - case productMetadataUnavailable - case trialUnavailable - case trialAlreadyUsed - case purchaseVerificationFailed - case purchasePending - case operationFailed(String) - case purchaseNotFound - - var errorDescription: String? { - switch self { - case .productUnavailable: - String(localized: "Termind Pro is not available from the App Store right now.") - case .productMetadataUnavailable: - String(localized: "The App Store is temporarily unavailable. You can keep using already unlocked features.") - case .trialUnavailable: - String(localized: "The free trial is unavailable from the App Store right now. Please try again later.") - case .trialAlreadyUsed: - String( - localized: - "This App Store account has already used the free trial. Restoring it does not restart the trial.") - case .operationFailed(let description): - description - case .purchaseVerificationFailed: - String(localized: "The App Store could not verify this purchase.") - case .purchasePending: - String(localized: "The purchase is waiting for App Store approval.") - case .purchaseNotFound: - String(localized: "No Termind Pro purchase or free trial was found for this App Store account.") - } - } -} +typealias ProAccessController = TermindInfrastructure.ProAccessController +typealias ProAccessControllerError = TermindInfrastructure.ProAccessControllerError diff --git a/TFM/App/RuntimeVersions.swift b/TFM/App/RuntimeVersions.swift index aae89306..1ae56f4d 100644 --- a/TFM/App/RuntimeVersions.swift +++ b/TFM/App/RuntimeVersions.swift @@ -1,6 +1,6 @@ -import CSSH2 import GhosttyKit -import OpenSSL +import TermindInfrastructure +import TermindRemoteInfrastructure nonisolated enum RuntimeVersions { static let unknown = "Unknown" @@ -10,14 +10,8 @@ nonisolated enum RuntimeVersions { return decode(info.version, length: Int(info.version_len)) ?? unknown } - static var libssh2: String { - guard let version = libssh2_version(0) else { return unknown } - return String(cString: version) - } - - static var openSSL: String { - "\(OPENSSL_version_major()).\(OPENSSL_version_minor()).\(OPENSSL_version_patch())" - } + static var libssh2: String { SSHRuntimeVersions.libssh2 } + static var openSSL: String { SSHRuntimeVersions.openSSL } static func decode(_ pointer: UnsafePointer?, length: Int) -> String? { guard let pointer, length >= 0 else { return nil } diff --git a/TFM/Application/Connections/ConnectionJourney.swift b/TFM/Application/Connections/ConnectionJourney.swift index 5d518630..57f11999 100644 --- a/TFM/Application/Connections/ConnectionJourney.swift +++ b/TFM/Application/Connections/ConnectionJourney.swift @@ -1,4 +1,5 @@ import Foundation +import TermindApplication import TermindDomain import struct TermindDomain.Host @@ -30,24 +31,6 @@ nonisolated struct ConnectionEndpoint: Identifiable, Hashable, Sendable { var role: Role } -nonisolated enum SSHRouteEvent: Hashable, Sendable { - case connecting(Host.ID) - case authenticating(Host.ID) - case authenticated(Host.ID) - case connected(Host.ID) - case initializing(Host.ID, String) -} - -typealias SSHRouteProgressHandler = @Sendable (SSHRouteEvent) -> Void - -nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable { - var username: String - var password: String? - var passphrase: String? - var keyID: SSHKeyRef.ID? - var identityID: Identity.ID? = nil -} - /// UI-facing state for one connection attempt. The transport emits facts; this model /// turns them into a monotonic route so reconnect probes cannot visually move /// a completed path backwards. diff --git a/TFM/Application/Connections/RemoteSessionHostStore.swift b/TFM/Application/Connections/RemoteSessionHostStore.swift index 2abdac62..94f27261 100644 --- a/TFM/Application/Connections/RemoteSessionHostStore.swift +++ b/TFM/Application/Connections/RemoteSessionHostStore.swift @@ -1,44 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// The host, trust, and credential capabilities required to establish a live -/// SSH-backed session. Session features depend on this port instead of gaining -/// the complete Vault mutation surface from `HostLibraryModel`. -@MainActor -protocol RemoteSessionHostStore: AnyObject, Sendable { - func host(_ id: Host.ID) -> Host? - func identity(_ id: Identity.ID) -> Identity? - func identity(for host: Host) -> Identity? - func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? - func knownHosts(for host: Host) -> [KnownHostEntry] - func route(for host: Host) -> [Host] - func expectedHostKeys( - for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] - ) throws -> Set - func prepareSSHConnection( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission] - ) throws -> SSHConnectionPreparation - func sshConnectionConfiguration( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] - ) throws -> SSHConnectionConfiguration - func sshJumpConfigurations( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] - ) throws -> [SSHRouteHopConfiguration] - func transportProxy(for host: Host) throws -> SSHTransportProxy? - func sshCredentials(for identity: Identity) -> SSHCredentialResolution - func passwordCredential(for identity: Identity) throws -> String? - func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? - func setConnectionStatus(_ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID) - func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) - func trust(_ entry: KnownHostEntry) - func markKnownHostMismatched(hostname: String, port: Int) - @discardableResult func upsert(_ identity: Identity) -> Bool - func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws - func upsert(_ host: Host) -} - -extension HostLibraryModel: RemoteSessionHostStore {} +typealias RemoteSessionHostStore = TermindApplication.RemoteSessionHostStore diff --git a/TFM/Application/Connections/SSHAuthenticationPrompt.swift b/TFM/Application/Connections/SSHAuthenticationPrompt.swift index 459c6831..389fd1c0 100644 --- a/TFM/Application/Connections/SSHAuthenticationPrompt.swift +++ b/TFM/Application/Connections/SSHAuthenticationPrompt.swift @@ -1,20 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Sendable { - nonisolated enum Mode: Hashable, Sendable { - case localIdentity - case serverChallenge - } - - let id = UUID() - var host: Host - var selectedIdentityID: Identity.ID? = nil - var username: String - var selectedKeyID: SSHKeyRef.ID? - var failureMessage: String? - var requiresPassphrase = false - var mode: Mode = .localIdentity -} +typealias SSHAuthenticationPrompt = TermindApplication.SSHAuthenticationPrompt diff --git a/TFM/Application/Connections/SSHConnectionPreparation.swift b/TFM/Application/Connections/SSHConnectionPreparation.swift index 0c6591fd..b1462ac9 100644 --- a/TFM/Application/Connections/SSHConnectionPreparation.swift +++ b/TFM/Application/Connections/SSHConnectionPreparation.swift @@ -1,123 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// A value snapshot taken on MainActor. Resolving Keychain references can block and -/// therefore happens on a detached worker, before any SSH transport is opened. -nonisolated struct SSHConnectionPreparation: Sendable { - struct Node: Sendable { - var host: Host - var identity: Identity? - var key: SSHKeyRef? - var submission: SSHAuthenticationSubmission? - var trustedKeys: Set - var proxyIdentity: Identity? - } - var nodes: [Node] - var secrets: any SecretStoring - - func resolve(discoveryOnly: Bool = false) async throws -> SSHConnectionConfiguration { - let task = Task.detached { try resolveSynchronously(discoveryOnly: discoveryOnly) } - return try await withTaskCancellationHandler { - let result = try await task.value - try Task.checkCancellation() - return result - } onCancel: { - task.cancel() - } - } - - private func resolveSynchronously(discoveryOnly: Bool) throws -> SSHConnectionConfiguration { - guard let target = nodes.last else { - throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) - } - var jumps: [SSHRouteHopConfiguration] = [] - for (offset, node) in nodes.dropLast().enumerated() { - do { - try Task.checkCancellation() - let material = try credentials(node) - guard !node.trustedKeys.isEmpty else { throw TermindSSHError.untrustedHost } - jumps.append( - SSHRouteHopConfiguration( - id: node.host.id, name: node.host.name, host: node.host.host, port: node.host.port, - username: material.0, credentials: material.1, - expectedHostKeys: node.trustedKeys, proxy: try proxy(node) - )) - } catch { - if error is CancellationError { throw error } - let ssh = error as? TermindSSHError - throw TermindSSHError.routeHost( - hostID: node.host.id, authentication: ssh?.isLocalAuthenticationRequirement == true, - serverChallenge: false, hostKey: isUntrusted(ssh), - message: String( - localized: "Jump Host \(offset + 1) (\(node.host.name)): \(error.localizedDescription)") - ) - } - } - try Task.checkCancellation() - let material = discoveryOnly ? ("", [SSHCredentials]()) : try credentials(target) - if !discoveryOnly, target.trustedKeys.isEmpty { throw TermindSSHError.untrustedHost } - return SSHConnectionConfiguration( - hostID: target.host.id, host: target.host.host, port: target.host.port, - username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys, - proxy: try proxy(target), jumps: jumps - ) - } - - private func credentials(_ node: Node) throws -> (String, [SSHCredentials]) { - let username = (node.submission?.username ?? node.identity?.username ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !username.isEmpty else { throw TermindSSHError.missingUsername } - var result: [SSHCredentials] = [] - if let key = node.key { - if key.requiresPassphraseEntry, node.submission?.passphrase?.isEmpty != false { - throw TermindSSHError.keyPassphraseRequired - } - if let credential = try? secrets.keyCredential(for: key.credentialRef, storage: key.storage) { - result.append( - credential.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: node.submission?.passphrase - )) - } - } - try Task.checkCancellation() - if let password = node.submission?.password { - result.append(.password(password)) - } else if node.submission == nil || node.submission?.identityID != nil, - let identity = node.identity, identity.usesPassword, - let password = try? secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) - { - result.append(.password(password)) - } - guard !result.isEmpty else { throw TermindSSHError.missingCredential } - return (username, result) - } - - private func proxy(_ node: Node) throws -> SSHTransportProxy? { - guard let proxy = node.host.proxy else { return nil } - guard proxy.isValid else { - throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) - } - var password: String? - if proxy.identityID != nil { - guard let identity = node.proxyIdentity, identity.usesPassword else { - throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable.")) - } - password = try secrets.string(for: identity.credentialRef, storage: identity.passwordStorage) - guard password != nil else { - throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) - } - } - return SSHTransportProxy( - kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, - username: proxy.username, password: password - ) - } - - private func isUntrusted(_ error: TermindSSHError?) -> Bool { - if case .untrustedHost = error { return true } - return false - } -} +typealias SSHConnectionPreparation = TermindApplication.SSHConnectionPreparation diff --git a/TFM/Application/Connections/SSHConnectionTypes.swift b/TFM/Application/Connections/SSHConnectionTypes.swift index 067dd992..933ea7cc 100644 --- a/TFM/Application/Connections/SSHConnectionTypes.swift +++ b/TFM/Application/Connections/SSHConnectionTypes.swift @@ -1,150 +1,13 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -nonisolated struct SSHHostKeyObservation: Hashable, Sendable { - var hostname: String - var port: Int - var algorithm: SSHKeyAlgorithm - var fingerprint: String - - var identity: SSHHostKeyIdentity { - SSHHostKeyIdentity(algorithm: algorithm, fingerprint: fingerprint) - } -} - -nonisolated struct SSHHostKeyPrompt: Hashable, Sendable { - enum Kind: Hashable, Sendable { - case firstUse - case additionalKey(trustedKeyCount: Int) - } - - var observation: SSHHostKeyObservation - var kind: Kind -} - -nonisolated enum SSHCredentials: Sendable { - case password(String) - case privateKey(privateKey: String, publicKey: String?, passphrase: String?) - case secureEnclave(credential: SecureEnclaveKeyCredential, publicKey: String) -} - -nonisolated extension StoredSSHKeyCredential { - func sshCredential( - publicKey: String, certificate: String = "", passphraseOverride: String? = nil - ) -> SSHCredentials { - switch self { - case .software(let secret): - .privateKey( - privateKey: secret.privateKey, - publicKey: certificate.isEmpty - ? (publicKey.isEmpty ? nil : publicKey) - : certificate, - passphrase: passphraseOverride - ) - case .secureEnclave(let credential): - .secureEnclave(credential: credential, publicKey: publicKey) - } - } -} - -nonisolated struct SSHConnectionConfiguration: Sendable { - var hostID: Host.ID - var host: String - var port: Int - var username: String - var credentials: [SSHCredentials] - var expectedHostKeys: Set - var proxy: SSHTransportProxy? - var jumps: [SSHRouteHopConfiguration] = [] -} - -nonisolated struct SSHCredentialResolution: Sendable { - var credentials: [SSHCredentials] - var failures: [String] - var hasConfiguredCredential: Bool - var requiredPassphraseKeyID: SSHKeyRef.ID? -} - -nonisolated enum TermindSSHError: LocalizedError, Sendable { - case socket(String) - case proxy(String) - case route(String) - case routeHost( - hostID: Host.ID, authentication: Bool, serverChallenge: Bool, - hostKey: Bool, message: String - ) - case library(String) - case authenticationFailed(String) - case hostKeyChanged(expected: [String], received: String) - case missingCredential - case keyPassphraseRequired - case missingUsername - case untrustedHost - case mismatchedHostKey - case disconnected - - var errorDescription: String? { - switch self { - case .socket(let message), .proxy(let message), .route(let message), - .library(let message), .authenticationFailed(let message): - message - case .routeHost(_, _, _, _, let message): message - case .hostKeyChanged(let expected, let received): - String( - localized: - "The host key changed. Expected one of \(expected.joined(separator: ", ")), received \(received).") - case .missingCredential: String(localized: "The selected identity has no usable password or private key.") - case .keyPassphraseRequired: String(localized: "Enter the Passphrase for the selected key.") - case .missingUsername: String(localized: "The selected host has no login username.") - case .untrustedHost: String(localized: "Connect to this host once before exporting a key to it.") - case .mismatchedHostKey: String(localized: "The saved host key no longer matches this host.") - case .disconnected: String(localized: "The SSH connection closed.") - } - } - - var shouldRequestAuthentication: Bool { - if case .authenticationFailed = self { return true } - if case .routeHost(_, let authentication, _, _, _) = self { return authentication } - return false - } - - var isLocalAuthenticationRequirement: Bool { - switch self { - case .missingCredential, .keyPassphraseRequired, .missingUsername: true - default: false - } - } - - var isServerAuthenticationChallenge: Bool { - if case .authenticationFailed = self { return true } - if case .routeHost(_, _, let serverChallenge, _, _) = self { return serverChallenge } - return false - } - - var routeHostID: Host.ID? { - guard case .routeHost(let id, _, _, _, _) = self else { return nil } - return id - } - - var shouldRequestHostKey: Bool { - guard case .routeHost(_, _, _, let hostKey, _) = self else { return false } - return hostKey - } - - var isHostKeyChanged: Bool { - if case .hostKeyChanged = self { return true } - return false - } - - var isRetryableForwardingFailure: Bool { - switch self { - case .socket, .library, .disconnected: true - case .proxy, .route, .routeHost, .authenticationFailed, .hostKeyChanged, - .missingCredential, .keyPassphraseRequired, .missingUsername, - .untrustedHost, .mismatchedHostKey: - false - } - } -} +typealias SSHHostKeyObservation = TermindApplication.SSHHostKeyObservation +typealias SSHHostKeyPrompt = TermindApplication.SSHHostKeyPrompt +typealias SSHCredentials = TermindApplication.SSHCredentials +typealias SSHConnectionConfiguration = TermindApplication.SSHConnectionConfiguration +typealias SSHCredentialResolution = TermindApplication.SSHCredentialResolution +typealias TermindSSHError = TermindApplication.TermindSSHError +typealias SSHTransportProxy = TermindApplication.SSHTransportProxy +typealias SSHRouteHopConfiguration = TermindApplication.SSHRouteHopConfiguration +typealias SSHRouteEvent = TermindApplication.SSHRouteEvent +typealias SSHRouteProgressHandler = TermindApplication.SSHRouteProgressHandler +typealias SSHAuthenticationSubmission = TermindApplication.SSHAuthenticationSubmission diff --git a/TFM/Application/Events/HostDeletionHandlers.swift b/TFM/Application/Events/HostDeletionHandlers.swift index 9bdd20af..3a0f2f15 100644 --- a/TFM/Application/Events/HostDeletionHandlers.swift +++ b/TFM/Application/Events/HostDeletionHandlers.swift @@ -1,43 +1,4 @@ -import Foundation import TermindApplication -import TermindDomain -@MainActor -final class RepositoryHostDeletionHandler: OutboxEventHandler { - nonisolated let handlerID: String - private let repository: any HostRepository - - init(handlerID: String, repository: any HostRepository) { - self.handlerID = handlerID - self.repository = repository - } - - func handle(type: String, payload: Data, eventID: UUID) async throws { - guard type == HostDeletionOutbox.eventType else { return } - let deletion = try JSONDecoder().decode(HostDeletionPayload.self, from: payload) - switch handlerID { - case HostDeletionOutbox.clearReferences: - try repository.clearReferencesForDeletedHost(deletion.hostID) - case HostDeletionOutbox.finalize: - try repository.finalizeHostDeletion(deletion.hostID) - default: - break - } - } -} - -@MainActor -final class RuntimeHostDeletionHandler: OutboxEventHandler { - nonisolated let handlerID: String - private let action: (HostDeletionPayload) -> Void - - init(handlerID: String, action: @escaping (HostDeletionPayload) -> Void) { - self.handlerID = handlerID - self.action = action - } - - func handle(type: String, payload: Data, eventID: UUID) async throws { - guard type == HostDeletionOutbox.eventType else { return } - action(try JSONDecoder().decode(HostDeletionPayload.self, from: payload)) - } -} +typealias RepositoryHostDeletionHandler = TermindApplication.RepositoryHostDeletionHandler +typealias RuntimeHostDeletionHandler = TermindApplication.RuntimeHostDeletionHandler diff --git a/TFM/Application/Events/OutboxRunner.swift b/TFM/Application/Events/OutboxRunner.swift index e234a9a2..f2a6222d 100644 --- a/TFM/Application/Events/OutboxRunner.swift +++ b/TFM/Application/Events/OutboxRunner.swift @@ -1,74 +1,3 @@ -import Foundation +import TermindApplication -/// Executes durable jobs one at a time. Persisted status is the idempotency -/// ledger, and handlers receive the stable event id for their own deduplication. -@MainActor -final class OutboxRunner { - private let store: any OutboxStore - private var handlers: [String: any OutboxEventHandler] - private var task: Task? - private let maximumAttempts: Int - - init( - store: any OutboxStore, handlers: [any OutboxEventHandler] = [], - maximumAttempts: Int = 5 - ) { - self.store = store - self.handlers = Dictionary(uniqueKeysWithValues: handlers.map { ($0.handlerID, $0) }) - self.maximumAttempts = maximumAttempts - } - - func register(_ handler: any OutboxEventHandler) { - handlers[handler.handlerID] = handler - } - - func start() { - guard task == nil else { return } - do { try store.resetInterruptedJobs() } catch { return } - task = Task { [weak self] in await self?.run() } - } - - func stop() { - task?.cancel() - task = nil - } - - private func run() async { - defer { task = nil } - while !Task.isCancelled { - do { - guard let job = try store.nextReady(at: Date()) else { - try await Task.sleep(for: .seconds(1)) - continue - } - guard let handler = handlers[job.handlerID] else { - try store.markFailed( - job.id, error: "No outbox handler is registered for \(job.handlerID).", - retryAt: nil - ) - continue - } - try store.markRunning(job.id) - do { - try await handler.handle( - type: job.eventType, payload: job.payload, eventID: job.eventID - ) - try store.markSucceeded(job.id) - } catch { - let attempt = job.attemptCount + 1 - let retryAt = - attempt >= maximumAttempts - ? nil - : Date().addingTimeInterval(pow(2, Double(attempt))) - try store.markFailed( - job.id, error: error.localizedDescription, retryAt: retryAt - ) - } - } catch is CancellationError { - return - } catch { - try? await Task.sleep(for: .seconds(1)) - } - } - } -} +typealias OutboxRunner = TermindApplication.OutboxRunner diff --git a/TFM/Application/Events/OutboxStore.swift b/TFM/Application/Events/OutboxStore.swift index 1be82d22..b99c4014 100644 --- a/TFM/Application/Events/OutboxStore.swift +++ b/TFM/Application/Events/OutboxStore.swift @@ -1,18 +1,4 @@ -import Foundation -import TermindDomain +import TermindApplication -@MainActor -protocol OutboxStore: AnyObject { - func enqueue(_ jobs: [OutboxJob]) throws - func nextReady(at date: Date) throws -> OutboxJob? - func markRunning(_ id: OutboxJob.ID) throws - func markSucceeded(_ id: OutboxJob.ID) throws - func markFailed(_ id: OutboxJob.ID, error: String, retryAt: Date?) throws - func resetInterruptedJobs() throws - func retryFailedJob(_ id: OutboxJob.ID) throws -} - -protocol OutboxEventHandler: AnyObject { - nonisolated var handlerID: String { get } - func handle(type: String, payload: Data, eventID: UUID) async throws -} +typealias OutboxStore = TermindApplication.OutboxStore +typealias OutboxEventHandler = TermindApplication.OutboxEventHandler diff --git a/TFM/Application/FeatureModels/AIConfigurationModel.swift b/TFM/Application/FeatureModels/AIConfigurationModel.swift index 79357fc7..32f1ff34 100644 --- a/TFM/Application/FeatureModels/AIConfigurationModel.swift +++ b/TFM/Application/FeatureModels/AIConfigurationModel.swift @@ -1,621 +1,3 @@ -import AgentKit -import Foundation -import TermindDomain +import TermindApplication -/// The single source of truth for model providers, their models, which model does -/// what, and the MCP servers. -/// -/// Its own store rather than more fields on `HostLibraryModel`: that one is about -/// machines you connect to, this one is about endpoints you spend a key at. -/// Provider and MCP configuration are projected from SwiftData in production -/// while previews can still construct an in-memory store. -@Observable -final class AIConfigurationModel { - private(set) var modelProviders: [ModelProvider] = [] - - /// What the user typed and picked for each provider's endpoint. - /// - /// Beside the providers rather than on them, because `ModelProvider` is - /// AgentKit's type and this app does not get to add a field to it. Neither - /// half survives a round trip through the composed result — a URL ending in - /// `/messages` looks the same whether it was typed or appended, and - /// `Automatic` looks the same as picking the format it resolved to — so - /// there is nothing to derive it from and it has to be kept. - /// - /// A missing entry is not a broken one: it reads as `Automatic` over the - /// provider.s stored `inferenceURL`, which resolves to the format already recorded - /// there. The editor shows the same endpoint either way. - private var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] - - private(set) var mcpServers: [MCPServer] = [] - - /// Explicit role assignments. Absent means "work it out" — see `resolve`. - private(set) var roles: [AIRole: AIModelRef] = [:] - - /// The last model picked in a conversation's model menu. - /// - /// This is what makes the app usable with no configuration at all: choosing - /// a model once, in the place you were already looking, quietly becomes the - /// default for every conversation that has not chosen. It sits below an - /// explicit Chat assignment and above first-available in `resolve`, so it - /// never overrides a decision the user made in Settings. - private(set) var lastSelectedModel: AIModelRef? - - /// The approval posture new and otherwise-unset conversations follow. - /// Stored in local preferences rather than provider configuration so it - /// survives relaunches without becoming part of the synced SwiftData model. - private(set) var lastSelectedPermissionMode: AgentPermissionMode - - /// Live MCP connection state, keyed by server. - /// - /// Here rather than on `MCPServer` for the same reason `HostLibraryModel.statuses` - /// is not on `Host`: it is session state, not user data, and it must never - /// be persisted or synced. - private(set) var mcpStatuses: [MCPServer.ID: ConnectionStatus] = [:] - - /// Session cache for API keys and service-account JSON. Production reads - /// through to the Data Protection Keychain; previews keep their fake secret - /// in memory so demo data never creates a real Keychain item. - private var secrets: [String: String] = [:] - private let aiSecrets: (any AISecretStoring)? - private let webSearchPersistence: (any AIWebSearchPersisting)? - private(set) var webSearch = AIWebSearchPreferences() - private let keychain: any SecretStoring - private let persistsSecrets: Bool - private let permissionDefaults: UserDefaults? - private(set) var secretStorageError: String? - private var persistence: (any AIConfigurationPersisting)? - private(set) var storageError: String? - - init( - modelProviders: [ModelProvider] = [], - endpoints: [ModelProvider.ID: ProviderEndpoint] = [:], - mcpServers: [MCPServer] = [], - roles: [AIRole: AIModelRef] = [:], - lastSelectedModel: AIModelRef? = nil, - lastSelectedPermissionMode: AgentPermissionMode = .askForApproval, - persistence: (any AIConfigurationPersisting)? = nil, - secrets secretStore: any SecretStoring = InMemorySecretStore(), - persistsSecrets: Bool = true, - permissionDefaults: UserDefaults? = nil, - aiSecrets: (any AISecretStoring)? = nil, - webSearchPersistence: (any AIWebSearchPersisting)? = nil - ) { - self.aiSecrets = aiSecrets - self.webSearchPersistence = webSearchPersistence - self.modelProviders = modelProviders - self.endpoints = endpoints - self.mcpServers = mcpServers - self.roles = roles - self.lastSelectedModel = lastSelectedModel - self.lastSelectedPermissionMode = - permissionDefaults - .flatMap { $0.string(forKey: SettingsKey.agentPermissionMode) } - .flatMap(AgentPermissionMode.init(rawValue:)) - ?? lastSelectedPermissionMode - self.persistence = persistence - self.keychain = secretStore - self.persistsSecrets = persistsSecrets - self.permissionDefaults = permissionDefaults - if let webSearchPersistence { - do { webSearch = try webSearchPersistence.loadWebSearch() } catch { - storageError = error.localizedDescription - } - } - guard let persistence else { return } - do { - let loaded = try persistence.load() - self.modelProviders = loaded.providers - self.endpoints = loaded.endpoints - self.roles = loaded.roles - self.lastSelectedModel = loaded.lastSelectedModel - self.mcpServers = loaded.mcpServers - } catch { - storageError = error.localizedDescription - } - } - - // MARK: - Reads - - func modelProvider(_ id: ModelProvider.ID) -> ModelProvider? { - modelProviders.first { $0.id == id } - } - - /// What the editor shows for a provider's endpoint. Falls back to reading - /// the composed URL as `Automatic` — see `endpoints`. - func endpoint(for id: ModelProvider.ID) -> ProviderEndpoint { - if let stored = endpoints[id] { return stored } - return ProviderEndpoint(url: modelProvider(id)?.inferenceURL ?? "") - } - - /// Providers whose models may be offered anywhere. A provider with no - /// credential is included: it may have hand-added models, and hiding it - /// would make "why is my model missing" unanswerable from the picker. - var enabledModelProviders: [ModelProvider] { - modelProviders.filter(\.isEnabled) - } - - /// Resolves a ref to the pair it names, or `nil` if either half has since - /// been deleted, renamed, or disabled. - /// - /// Every step of `resolve` runs through here, which is what stops a stale - /// assignment from silently pointing at nothing. - /// - /// A missing URL is deliberately *not* one of the disqualifying conditions, - /// for the same reason a missing credential is not: the model is sitting - /// right there in Settings, and a picker that quietly skips it makes "why is - /// my model missing" unanswerable. Sending to a blank URL now fails with a - /// bad-URL error naming the problem, which is the better of the two — there - /// is no default endpoint left for it to reach instead. - func lookup(_ ref: AIModelRef) -> (provider: ModelProvider, model: AIModel)? { - guard - let provider = modelProvider(ref.modelProviderID), - provider.isEnabled, - let model = provider.model(ref.modelID) - else { return nil } - return (provider, model) - } - - /// The fallback of last resort: the first chat model of the first enabled - /// provider, in the order the lists are shown. - var firstAvailableModel: AIModelRef? { - for provider in enabledModelProviders { - if let model = provider.chatModels.first { - return AIModelRef(modelProviderID: provider.id, modelID: model.id) - } - } - return nil - } - - /// Which model answers a given job. - /// - /// One chain, and no step of it is required: - /// - /// 1. an explicit assignment for this role - /// 2. for non-chat roles, whatever Chat resolves to - /// 3. the last model picked in a conversation's menu - /// 4. the first chat model of the first enabled provider - /// - /// Any step whose ref no longer resolves is skipped rather than returning - /// nothing, so deleting a provider degrades the app instead of breaking it. - /// The practical effect: add one provider, press Fetch, and the assistant - /// works — the role pickers are for tuning, not for setup. - func resolve(_ role: AIRole) -> (provider: ModelProvider, model: AIModel)? { - if let ref = roles[role], let hit = lookup(ref) { return hit } - if role != .chat, let ref = roles[.chat], let hit = lookup(ref) { return hit } - if let ref = lastSelectedModel, let hit = lookup(ref) { return hit } - if let ref = firstAvailableModel, let hit = lookup(ref) { return hit } - return nil - } - - /// The model a conversation will actually use. - /// - /// Its own choice if it made one, and whatever `.chat` resolves to if it did - /// not. A conversation is left unset rather than assigned a model on - /// creation, so this is the only place that difference has to be understood - /// — the Assistant sidebar's model menu, the composer's caption, and the status bar - /// all ask here instead of each reimplementing the fallback slightly - /// differently. - func resolve(conversation model: AIModelRef?) -> (provider: ModelProvider, model: AIModel)? { - if let model, let hit = lookup(model) { return hit } - return resolve(.chat) - } - - /// An explicit conversation choice wins; otherwise the app-wide latest - /// choice is the default. The latter begins at the safest posture. - func resolve(conversation permissionMode: AgentPermissionMode?) -> AgentPermissionMode { - permissionMode ?? lastSelectedPermissionMode - } - - /// Whether a role is showing an explicit choice or following the chain. - /// The picker needs this to know when to show its unset tag. - func assignment(for role: AIRole) -> AIModelRef? { - guard let ref = roles[role], lookup(ref) != nil else { return nil } - return ref - } - - /// Which roles would lose their explicit model if a provider were deleted. - /// - /// The counterpart of `HostLibraryModel.identities(using:)`, and used for the same - /// reason: a delete button should say what it takes with it. - func roles(using modelProviderID: ModelProvider.ID) -> [AIRole] { - AIRole.allCases.filter { roles[$0]?.modelProviderID == modelProviderID } - } - - func mcpServer(_ id: MCPServer.ID) -> MCPServer? { - mcpServers.first { $0.id == id } - } - - func mcpStatus(of id: MCPServer.ID) -> ConnectionStatus { - mcpStatuses[id] ?? .disconnected - } - - func hasMCPNamespaceConflict(_ namespaceID: String, excluding id: MCPServer.ID) -> Bool { - !namespaceID.isEmpty - && mcpServers.contains { - $0.id != id && $0.effectiveNamespaceID == namespaceID - } - } - - /// Every tool the model may be offered: enabled tools on enabled servers. - var availableTools: [(server: MCPServer, tool: MCPTool)] { - usableMCPServers.flatMap { server in - server.enabledTools.map { (server, $0) } - } - } - - var conflictingMCPServerIDs: Set { - var names = Set() - var conflicts = Set() - for server in mcpServers.sorted(by: { $0.id.uuidString < $1.id.uuidString }) { - if !server.effectiveNamespaceID.isEmpty, !names.insert(server.effectiveNamespaceID).inserted { - conflicts.insert(server.id) - } - } - return conflicts - } - - var usableMCPServers: [MCPServer] { - let conflicts = conflictingMCPServerIDs - return mcpServers.filter { $0.isEnabled && !conflicts.contains($0.id) } - } - - func setWebSearch(_ value: AIWebSearchPreferences) { - do { - try webSearchPersistence?.saveWebSearch(value) - webSearch = value - storageError = nil - } catch { storageError = error.localizedDescription } - } - - /// Applies an already committed replica. It never calls the local write path. - func applyRemoteSnapshot(_ snapshot: AIConfigurationSnapshot, webSearch: AIWebSearchPreferences) { - let previousServers = Dictionary(uniqueKeysWithValues: mcpServers.map { ($0.id, $0) }) - modelProviders = snapshot.providers - endpoints = snapshot.endpoints - roles = snapshot.roles - lastSelectedModel = snapshot.lastSelectedModel - mcpServers = snapshot.mcpServers - mcpStatuses = mcpStatuses.filter { id, _ in - previousServers[id] == mcpServers.first { $0.id == id } - } - self.webSearch = webSearch - secrets.removeAll() - storageError = nil - } - - // MARK: - Secrets - - /// The secret behind a `credentialRef`, loaded from the Termind Data - /// Protection Keychain and cached only for this process. - func secret(for ref: String) -> String? { - guard !ref.isEmpty else { return nil } - if let aiSecrets, persistsSecrets { - do { - let value = try aiSecrets.string(for: ref) - secretStorageError = nil - return value - } catch { - secretStorageError = error.localizedDescription - return nil - } - } - if let cached = secrets[ref] { return cached } - guard persistsSecrets else { return nil } - do { - let value = try keychain.string(for: ref, storage: .thisDevice) - if let value { secrets[ref] = value } - secretStorageError = nil - return value - } catch { - secretStorageError = error.localizedDescription - return nil - } - } - - /// Stores or clears a secret. `nil` forgets it, which is what emptying the - /// field in an editor has to mean. - func setSecret(_ secret: String?, for ref: String) { - guard !ref.isEmpty else { return } - do { - if persistsSecrets { - if let aiSecrets { - try aiSecrets.setString(secret, for: ref) - } else { - try keychain.setString(secret, for: ref, storage: .thisDevice) - } - } - secrets[ref] = secret - secretStorageError = nil - } catch { - secretStorageError = error.localizedDescription - } - } - - /// Mints a reference for a new secret. The plaintext is handed over - /// separately so the two are never in one value — the trick - /// `NewIdentitySheet` already uses for passwords. - static func makeCredentialRef(_ kind: String = "key") -> String { - "termind.ai.\(kind).\(UUID().uuidString)" - } - - // MARK: - Model provider writes - - func upsert(_ provider: ModelProvider) { - mutatePersistedState { - if let index = modelProviders.firstIndex(where: { $0.id == provider.id }) { - modelProviders[index] = provider - } else { - modelProviders.append(provider) - } - } - } - - /// The only place `url` and `apiFormat` are ever written. - /// - /// Everything else edits a name, a key or a model list. Keeping the - /// derivation in one function is what lets the caption under the URL field - /// promise where requests land: there is exactly one rule turning a typed - /// address and a picked type into an address, and both the caption and the - /// request read it from the same place. - func setEndpoint(_ endpoint: ProviderEndpoint, for id: ModelProvider.ID) { - guard var provider = modelProvider(id) else { return } - provider.inferenceURL = endpoint.requestURL - provider.baseURL = endpoint.baseURL - provider.apiFormat = endpoint.format - endpoints[id] = endpoint - upsert(provider) - } - - /// Removes a provider along with everything that only made sense while it - /// existed: its secrets, the role assignments pointing at it, and the - /// remembered selection if that was one of its models. - /// - /// The same discipline as `HostLibraryModel.delete(identityID:)`. Leaving any of - /// these behind would give `resolve` a ref that looks valid and is not, and - /// the failure would surface at send time rather than here. - func delete(modelProviderID: ModelProvider.ID) { - guard let provider = modelProvider(modelProviderID) else { return } - - let deleted = mutatePersistedState { - for role in roles(using: modelProviderID) { - roles[role] = nil - } - if lastSelectedModel?.modelProviderID == modelProviderID { - lastSelectedModel = nil - } - modelProviders.removeAll { $0.id == modelProviderID } - endpoints[modelProviderID] = nil - } - guard deleted else { return } - setSecret(nil, for: provider.credentialRef) - } - - func setModelProviderEnabled(_ enabled: Bool, for id: ModelProvider.ID) { - mutatePersistedState { - guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } - modelProviders[index].isEnabled = enabled - } - } - - // MARK: - Model writes - - /// Replaces the whole model list. - /// - /// Hand-added models are kept and merged rather than wiped: someone who - /// typed a model the endpoint does not advertise — a private deployment, a - /// preview id — should not lose it. A fetched row wins on conflict, since - /// the server is better informed than a guess. - /// - /// Not what the Fetch button calls. A fetch offers a list and the user picks - /// from it (see `ModelListEditor`), so the additions arrive one at a time - /// through `addModel`. This exists for a caller that really does mean "this - /// is now the list". - func replaceModels(_ fetched: [AIModel], for id: ModelProvider.ID) { - mutatePersistedState { - guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } - let previous = Dictionary( - uniqueKeysWithValues: modelProviders[index].models.map { ($0.id, $0) } - ) - let fetchedIDs = Set(fetched.map(\.id)) - let merged = fetched.map { fresh in - var fresh = fresh - fresh.reasoningProfile = previous[fresh.id]?.reasoningProfile - fresh.isShownInAssistant = - previous[fresh.id]?.isShownInAssistant ?? fresh.isShownInAssistant - return fresh - } - let kept = modelProviders[index].models.filter { - $0.origin == .manual && !fetchedIDs.contains($0.id) - } - modelProviders[index].models = merged + kept - modelProviders[index].modelsFetchedAt = Date() - } - } - - /// Records that the endpoint was asked, whatever the user then chose to add. - /// - /// Separate from adding, because "when did I last check what this endpoint - /// offers" is a different question from "what did I keep", and the answer to - /// the first is useful even when the answer to the second is "nothing". - func markModelsFetched(for id: ModelProvider.ID) { - mutatePersistedState { - guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } - modelProviders[index].modelsFetchedAt = Date() - } - } - - func addModel(_ model: AIModel, to id: ModelProvider.ID) { - mutatePersistedState { - guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } - guard !modelProviders[index].models.contains(where: { $0.id == model.id }) else { return } - modelProviders[index].models.append(model) - } - } - - func updateModel(_ model: AIModel, in id: ModelProvider.ID) { - mutatePersistedState { - guard let index = modelProviders.firstIndex(where: { $0.id == id }) else { return } - guard let slot = modelProviders[index].models.firstIndex(where: { $0.id == model.id }) else { return } - modelProviders[index].models[slot] = model - } - } - - /// Removes models, and unhooks anything that pointed at them. - /// - /// Same reasoning as `delete(modelProviderID:)`: a role holding a ref to a model - /// that no longer exists is a failure deferred to send time. - func removeModels(_ ids: Set, from modelProviderID: ModelProvider.ID) { - mutatePersistedState { - guard let index = modelProviders.firstIndex(where: { $0.id == modelProviderID }) else { return } - modelProviders[index].models.removeAll { ids.contains($0.id) } - - for role in AIRole.allCases { - guard let ref = roles[role], ref.modelProviderID == modelProviderID, ids.contains(ref.modelID) else { - continue - } - roles[role] = nil - } - if let ref = lastSelectedModel, ref.modelProviderID == modelProviderID, ids.contains(ref.modelID) { - lastSelectedModel = nil - } - } - } - - // MARK: - Role writes - - func assign(_ ref: AIModelRef?, to role: AIRole) { - mutatePersistedState { roles[role] = ref } - } - - /// Records a choice made in a conversation's model menu. - /// - /// Not an assignment: it feeds step 3 of `resolve`, so it changes the - /// default without overwriting anything the user set in Settings. - func select(_ ref: AIModelRef) { - mutatePersistedState { lastSelectedModel = ref } - } - - /// Records a composer permission choice for subsequent unset conversations - /// and future app launches. It does not alter command or MCP execution policy. - func select(_ permissionMode: AgentPermissionMode) { - lastSelectedPermissionMode = permissionMode - permissionDefaults?.set(permissionMode.rawValue, forKey: SettingsKey.agentPermissionMode) - } - - // MARK: - Provider persistence - - @discardableResult - private func mutatePersistedState(_ mutation: () -> Void) -> Bool { - let previous = persistedSnapshot - mutation() - guard let persistence else { return true } - do { - try persistence.save(persistedSnapshot) - storageError = nil - return true - } catch { - modelProviders = previous.providers - endpoints = previous.endpoints - roles = previous.roles - lastSelectedModel = previous.lastSelectedModel - mcpServers = previous.mcpServers - storageError = error.localizedDescription - return false - } - } - - private var persistedSnapshot: AIConfigurationSnapshot { - AIConfigurationSnapshot( - providers: modelProviders, - endpoints: endpoints, - roles: roles, - lastSelectedModel: lastSelectedModel, - mcpServers: mcpServers - ) - } - - // MARK: - MCP writes - - func upsert(_ server: MCPServer) { - mutatePersistedState { - if let index = mcpServers.firstIndex(where: { $0.id == server.id }) { - mcpServers[index] = server - } else { - mcpServers.append(server) - } - } - } - - /// Locks a valid, unique draft namespace without changing the UUID identity. - @discardableResult - func lockMCPNamespace(for id: MCPServer.ID) -> Bool { - guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return false } - if !mcpServers[index].namespaceID.isEmpty { return true } - let namespaceID = mcpServers[index].effectiveNamespaceID - guard !namespaceID.isEmpty, - !hasMCPNamespaceConflict(namespaceID, excluding: id) - else { return false } - return mutatePersistedState { mcpServers[index].namespaceID = namespaceID } - } - - func delete(serverID: MCPServer.ID) { - guard let server = mcpServer(serverID) else { return } - let deleted = mutatePersistedState { mcpServers.removeAll { $0.id == serverID } } - guard deleted else { return } - setSecret(nil, for: server.credentialRef) - mcpStatuses[serverID] = nil - } - - func setServerEnabled(_ enabled: Bool, for id: MCPServer.ID) { - mutatePersistedState { - guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } - mcpServers[index].isEnabled = enabled - } - } - - /// What a successful `Connect` writes back. - /// - /// A tool keeps its access policy across rediscovery — otherwise every - /// reconnect would silently undo the decision made about this server. A - /// genuinely new tool arrives at the default, which means always asking. - func setTools(_ discovered: [MCPTool], version: String, for id: MCPServer.ID) { - mutatePersistedState { - guard let index = mcpServers.firstIndex(where: { $0.id == id }) else { return } - let previous = Dictionary( - uniqueKeysWithValues: mcpServers[index].tools.map { ($0.id, $0) } - ) - - mcpServers[index].tools = discovered.map { tool in - guard let old = previous[tool.id] else { return tool } - var merged = tool - merged.accessPolicy = old.accessPolicy - return merged - } - mcpServers[index].negotiatedVersion = version - mcpServers[index].lastConnectedAt = Date() - } - } - - func setToolAccessPolicy( - _ policy: MCPTool.AccessPolicy, tool toolID: String, on serverID: MCPServer.ID - ) { - mutate(toolID, on: serverID) { $0.accessPolicy = policy } - } - - private func mutate( - _ toolID: String, on serverID: MCPServer.ID, - _ change: (inout MCPTool) -> Void - ) { - mutatePersistedState { - guard let index = mcpServers.firstIndex(where: { $0.id == serverID }) else { return } - guard let slot = mcpServers[index].tools.firstIndex(where: { $0.id == toolID }) - else { return } - change(&mcpServers[index].tools[slot]) - } - } - - func setMCPStatus(_ status: ConnectionStatus, for id: MCPServer.ID) { - mcpStatuses[id] = status - } -} +typealias AIConfigurationModel = TermindApplication.AIConfigurationModel diff --git a/TFM/Application/FeatureModels/CredentialLibraryModel.swift b/TFM/Application/FeatureModels/CredentialLibraryModel.swift index 24313f87..6b03c3ed 100644 --- a/TFM/Application/FeatureModels/CredentialLibraryModel.swift +++ b/TFM/Application/FeatureModels/CredentialLibraryModel.swift @@ -1,58 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// The Keychain feature's view of the shared vault. -/// -/// Keeping this surface separate prevents credential screens from mutating hosts, -/// snippets, forwarding rules, or transfers merely because those records share a -/// persistence snapshot. -@Observable -final class CredentialLibraryModel { - private let library: HostLibraryModel - - init(library: HostLibraryModel) { - self.library = library - } - - var hosts: [Host] { library.hosts } - var keys: [SSHKeyRef] { library.keys } - var identities: [Identity] { library.identities } - var storageError: String? { library.storageError } - - func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { library.key(id) } - func identities(using key: SSHKeyRef.ID) -> [Identity] { library.identities(using: key) } - func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } - func searchHosts(_ query: String) -> [Host] { library.searchHosts(query) } - func searchKeys(_ query: String) -> [SSHKeyRef] { library.searchKeys(query) } - func searchIdentities(_ query: String) -> [Identity] { library.searchIdentities(query) } - func passwordAvailability(for identity: Identity) -> SecretAvailability { - library.passwordAvailability(for: identity) - } - func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { - library.keyAvailability(for: key) - } - func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { - try library.keyCredential(for: key) - } - func keyExportUnavailableReason(for host: Host) -> String? { - library.keyExportUnavailableReason(for: host) - } - func sshConnectionConfiguration(for host: Host) throws -> SSHConnectionConfiguration { - try library.sshConnectionConfiguration(for: host) - } - - func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { - try library.upsert(key, credential: credential) - } - @discardableResult - func delete(identityID: Identity.ID) -> Bool { library.delete(identityID: identityID) } - func delete(keyID: SSHKeyRef.ID) throws { try library.delete(keyID: keyID) } - func move(keyIDs: [SSHKeyRef.ID], before next: SSHKeyRef.ID? = nil) { - library.move(keyIDs: keyIDs, before: next) - } - func move(identityIDs: [Identity.ID], before next: Identity.ID? = nil) { - library.move(identityIDs: identityIDs, before: next) - } -} +typealias CredentialLibraryModel = TermindApplication.CredentialLibraryModel diff --git a/TFM/Application/FeatureModels/ForwardingLibraryModel.swift b/TFM/Application/FeatureModels/ForwardingLibraryModel.swift index 1aabbae0..98cbd8b9 100644 --- a/TFM/Application/FeatureModels/ForwardingLibraryModel.swift +++ b/TFM/Application/FeatureModels/ForwardingLibraryModel.swift @@ -1,22 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// Read and write access needed by the port-forwarding feature. -@Observable -final class ForwardingLibraryModel { - private let library: HostLibraryModel - - init(library: HostLibraryModel) { - self.library = library - } - - func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? { - library.portForwarding(id) - } - func routeSummary(for host: Host) -> String { library.routeSummary(for: host) } - func sshCommand(for rule: PortForwardingRule, host: Host? = nil) -> String? { - library.sshCommand(for: rule, host: host) - } -} +typealias ForwardingLibraryModel = TermindApplication.ForwardingLibraryModel diff --git a/TFM/Application/FeatureModels/HostLibraryModel.swift b/TFM/Application/FeatureModels/HostLibraryModel.swift index 8fea7d4d..6c5846f1 100644 --- a/TFM/Application/FeatureModels/HostLibraryModel.swift +++ b/TFM/Application/FeatureModels/HostLibraryModel.swift @@ -1,1825 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// The single source of truth for saved hosts, groups, keys, snippets, and port -/// forwarding rules. -/// -/// Core host metadata is backed by SwiftData in the app and projected into value -/// types for the views. Previews can still construct an in-memory store directly. -@Observable -final class HostLibraryModel { - private(set) var hosts: [Host] = [] - private(set) var groups: [HostGroup] = [] - private(set) var keys: [SSHKeyRef] = [] - private(set) var identities: [Identity] = [] - private(set) var snippets: [Snippet] = [] - private(set) var snippetPackages: [SnippetPackage] = [] - private(set) var portForwardings: [PortForwardingRule] = [] - private(set) var sftpTransfers: [SFTPTransfer] = [] - private(set) var knownHosts: [KnownHostEntry] = [] - - /// The latest local persistence failure. Secret text is never included. - private(set) var storageError: String? - private let vault: VaultStore - private let keychain: any SecretStoring - /// Runtime resources live in AppModel, so deletion announces before model - /// references are removed and the controller can stop listeners cleanly. - var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)? - var willDeletePortForwardings: (([PortForwardingRule.ID]) -> Void)? - var didPersistCore: (() -> Void)? { - get { vault.didPersist } - set { vault.didPersist = newValue } - } - var didRecordVaultChanges: ((VaultMutationSet) -> Void)? { - get { vault.didRecordChanges } - set { vault.didRecordChanges = newValue } - } - - func reportStorageError(_ error: any Error) { - storageError = error.localizedDescription - } - - /// Live connection state, keyed by host. Lives here rather than on `Host` - /// because it is session state, not user data — it must never be persisted - /// or synced. - private(set) var statuses: [Host.ID: ConnectionStatus] = [:] - private var connectionStatuses: [UUID: (hostID: Host.ID, status: ConnectionStatus)] = [:] - - /// Whether the Hosts page's Ungrouped section is open. - /// - /// Lives here rather than as view state because a real group keeps its - /// `isExpanded` on the model: if this were a `@State` on the page, folding - /// Ungrouped would spring back open on the next rebuild while folding - /// Production stayed folded, and there would be no way to explain the - /// difference to whoever noticed. - private(set) var isUngroupedExpanded = true - private(set) var isSnippetsUngroupedExpanded = true - - init( - hosts: [Host] = [], - groups: [HostGroup] = [], - keys: [SSHKeyRef] = [], - identities: [Identity] = [], - snippets: [Snippet] = [], - snippetPackages: [SnippetPackage] = [], - portForwardings: [PortForwardingRule] = [], - sftpTransfers: [SFTPTransfer] = [], - knownHosts: [KnownHostEntry] = [], - persistence: (any VaultPersisting)? = nil, - secrets: any SecretStoring = InMemorySecretStore() - ) { - self.hosts = hosts - self.groups = groups - self.keys = normalizedManualOrder( - keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label - ) - self.identities = normalizedManualOrder( - identities, sortIndex: \Identity.sortIndex, name: \Identity.name - ) - self.snippets = normalizedSnippetOrder(snippets) - self.snippetPackages = snippetPackages - self.portForwardings = portForwardings - self.sftpTransfers = sftpTransfers - self.knownHosts = knownHosts - self.vault = VaultStore(persistence: persistence) - self.keychain = secrets - do { - let loaded = try vault.load( - fallback: VaultSnapshot( - hosts: hosts, groups: groups, identities: identities, keys: keys, - knownHosts: knownHosts, snippets: snippets, - snippetPackages: snippetPackages, portForwardings: portForwardings - ) - ) - self.hosts = loaded.hosts - self.groups = loaded.groups - self.identities = loaded.identities.sorted { - manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name) - } - self.keys = loaded.keys.sorted { - manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label) - } - self.knownHosts = loaded.knownHosts - self.snippets = loaded.snippets.sorted { $0.sortIndex < $1.sortIndex } - self.snippetPackages = loaded.snippetPackages - self.portForwardings = loaded.portForwardings - } catch { - storageError = error.localizedDescription - } - } - - // MARK: - Reads - - func host(_ id: Host.ID) -> Host? { - hosts.first { $0.id == id } - } - - func group(_ id: HostGroup.ID) -> HostGroup? { - groups.first { $0.id == id } - } - - func snippet(_ id: Snippet.ID) -> Snippet? { - snippets.first { $0.id == id } - } - - func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? { - snippetPackages.first { $0.id == id } - } - - var rootSnippetPackages: [SnippetPackage] { - snippetPackages.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex } - } - - func childSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { - snippetPackages.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex } - } - - func descendantSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { - childSnippetPackages(of: parent).flatMap { - [$0] + descendantSnippetPackages(of: $0.id) - } - } - - func snippets(in package: SnippetPackage.ID?) -> [Snippet] { - snippets.filter { $0.packageID == package }.sorted { $0.sortIndex < $1.sortIndex } - } - - func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? { - guard let packageID, let package = snippetPackage(packageID) else { return nil } - var names = [package.name] - var parentID = package.parentID - var visited: Set = [package.id] - while let id = parentID, let parent = snippetPackage(id), visited.insert(id).inserted { - names.append(parent.name) - parentID = parent.parentID - } - return names.reversed().joined(separator: " / ") - } - - func status(of id: Host.ID) -> ConnectionStatus { - if statuses[id] == .connected { return .connected } - if sftpTransfers.contains(where: { - $0.involves(id) && $0.state == .running - }) { - return .connected - } - if let status = statuses[id] { return status } - if sftpTransfers.contains(where: { - $0.involves(id) && $0.state == .queued - }) { - return .connecting - } - return .disconnected - } - - /// Top-level groups, in sort order. - var rootGroups: [HostGroup] { - groups.filter { $0.parentID == nil }.sorted { $0.sortIndex < $1.sortIndex } - } - - func childGroups(of parent: HostGroup.ID) -> [HostGroup] { - groups.filter { $0.parentID == parent }.sorted { $0.sortIndex < $1.sortIndex } - } - - /// Every group below `parent`, at any depth, parents before children. - /// - /// What a delete has to take with it, and — read the other way — the set a - /// group may *not* be re-filed into, since a group cannot become its own - /// descendant. - func descendantGroups(of parent: HostGroup.ID) -> [HostGroup] { - childGroups(of: parent).flatMap { [$0] + descendantGroups(of: $0.id) } - } - - /// Hosts directly inside a group — `nil` means "not filed in any group". - func hosts(in group: HostGroup.ID?) -> [Host] { - hosts.filter { $0.groupID == group }.sorted { $0.sortIndex < $1.sortIndex } - } - - /// Hosts anywhere in a group's subtree. The number a delete has to warn - /// about: deleting Production takes Edge with it, so the machines filed in - /// Edge are just as much at stake as the ones filed in Production. - func hosts(inTreeOf group: HostGroup.ID) -> [Host] { - let tree = Set([group] + descendantGroups(of: group).map(\.id)) - return hosts.filter { host in host.groupID.map(tree.contains) ?? false } - } - - func portForwardings(for host: Host.ID) -> [PortForwardingRule] { - portForwardings.filter { $0.hostID == host } - } - - func portForwarding(_ id: PortForwardingRule.ID) -> PortForwardingRule? { - portForwardings.first { $0.id == id } - } - - func sftpTransfers(for host: Host.ID) -> [SFTPTransfer] { - sftpTransfers.filter { $0.involves(host) } - } - - func key(_ id: SSHKeyRef.ID) -> SSHKeyRef? { - keys.first { $0.id == id } - } - - func identity(_ id: Identity.ID) -> Identity? { - identities.first { $0.id == id } - } - - /// The login a host connects with, resolved through the store so renaming an - /// identity renames it everywhere it is shown. - func identity(for host: Host) -> Identity? { - host.identityID.flatMap { identity($0) } - } - - /// `user@host:port` — what the UI shows almost everywhere a host is named. - /// - /// Falls back to the bare address when no identity is set, rather than - /// inventing a login: a host that does not yet know who it connects as - /// should look like one. - func loginAddress(for host: Host) -> String { - guard let username = identity(for: host)?.username, !username.isEmpty else { - return host.displayAddress - } - return "\(username)@\(host.displayAddress)" - } - - /// Which identities would break if a key were deleted. The Keys list shows - /// this count, because "delete" needs to say what it takes with it. - func identities(using key: SSHKeyRef.ID) -> [Identity] { - identities.filter { $0.keyID == key } - } - - /// The pinned host keys for a saved host, matched the way OpenSSH matches: - /// hostname plus port. Newest keys are shown first in the host's details. - func knownHosts(for host: Host) -> [KnownHostEntry] { - knownHosts - .filter { $0.matches(hostname: host.host, port: host.port) } - .sorted { - if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt } - if $0.fingerprint != $1.fingerprint { return $0.fingerprint < $1.fingerprint } - return $0.id.uuidString < $1.id.uuidString - } - } - - /// Resolves persistent trust plus approvals scoped to the current connection - /// journey. Temporary approvals never enter SwiftData or sync. - func expectedHostKeys( - for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] - ) throws -> Set { - var keys = Set(knownHosts(for: host).map(\.identity)) - if let temporary = temporaryHostKeys[host.id] { - keys.insert(temporary) - } - guard !keys.isEmpty else { throw TermindSSHError.untrustedHost } - return keys - } - - /// Why a saved host cannot currently receive a key without stopping for - /// another question. Export is deliberately limited to hosts whose login - /// and host-key trust have already been established elsewhere in the app. - func keyExportUnavailableReason(for host: Host) -> String? { - guard let identity = identity(for: host) else { - return String(localized: "No identity") - } - guard !identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return String(localized: "No username") - } - let hasAvailablePassword = passwordAvailability(for: identity) == .available - let hasUsableKey = - identity.keyID.flatMap { key($0) }.map { - keyAvailability(for: $0) == .available - } == true - guard hasAvailablePassword || hasUsableKey else { - return String(localized: "No saved credential") - } - let knownHosts = knownHosts(for: host) - guard !knownHosts.isEmpty else { - return String(localized: "Connect once to trust this host") - } - guard !knownHosts.contains(where: \.isMismatched) else { - return String(localized: "Host key changed") - } - return nil - } - - /// Resolves model references and Keychain secrets before work leaves the - /// main actor. The resulting value is Sendable and contains no persistence - /// objects, so several one-shot exports can safely run in parallel. - func prepareSSHConnection( - for host: Host, submissions: [Host.ID: SSHAuthenticationSubmission] - ) throws -> SSHConnectionPreparation { - guard let route = validatedRoute(for: host) else { - throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) - } - return SSHConnectionPreparation( - nodes: route.map { node in - let submission = submissions[node.id] - let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node) - let keyID = submission != nil ? submission?.keyID : identity?.keyID - return SSHConnectionPreparation.Node( - host: node, identity: identity, - key: keyID.flatMap { key($0) }, - submission: submission, trustedKeys: Set(knownHosts(for: node).map(\.identity)), - proxyIdentity: node.proxy?.identityID.flatMap { self.identity($0) } - ) - }, - secrets: keychain - ) - } - - func sshConnectionConfiguration( - for host: Host, - submissions: [Host.ID: SSHAuthenticationSubmission] = [:], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] - ) throws -> SSHConnectionConfiguration { - let username: String - let credentials: [SSHCredentials] - if let submission = submissions[host.id] { - username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !username.isEmpty else { throw TermindSSHError.missingUsername } - credentials = try sshCredentials(for: submission) - } else { - guard let identity = identity(for: host) else { - throw TermindSSHError.missingCredential - } - username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !username.isEmpty else { throw TermindSSHError.missingUsername } - let resolution = sshCredentials(for: identity) - guard resolution.requiredPassphraseKeyID == nil else { - throw TermindSSHError.keyPassphraseRequired - } - credentials = resolution.credentials - } - guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } - let expectedHostKeys = try expectedHostKeys( - for: host, temporaryHostKeys: temporaryHostKeys - ) - - return SSHConnectionConfiguration( - hostID: host.id, host: host.host, port: host.port, username: username, - credentials: credentials, - expectedHostKeys: expectedHostKeys, - proxy: try transportProxy(for: host), - jumps: try sshJumpConfigurations( - for: host, submissions: submissions, - temporaryHostKeys: temporaryHostKeys - ) - ) - } - - func sshJumpConfigurations( - for host: Host, - submissions: [Host.ID: SSHAuthenticationSubmission], - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:] - ) throws -> [SSHRouteHopConfiguration] { - guard let route = validatedRoute(for: host) else { - throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts.")) - } - return try route.dropLast().enumerated().map { offset, jump in - do { - if let submission = submissions[jump.id] { - return try sshRouteHopConfiguration( - for: jump, submission: submission, - temporaryHostKeys: temporaryHostKeys - ) - } - return try sshRouteHopConfiguration( - for: jump, temporaryHostKeys: temporaryHostKeys - ) - } catch { - let authentication = - switch error as? TermindSSHError { - case .missingCredential, .missingUsername, .keyPassphraseRequired: true - default: false - } - let hostKey = - switch error as? TermindSSHError { - case .untrustedHost: true - default: false - } - throw TermindSSHError.routeHost( - hostID: jump.id, authentication: authentication, - serverChallenge: false, hostKey: hostKey, - message: String(localized: "Jump Host \(offset + 1) (\(jump.name)): \(error.localizedDescription)") - ) - } - } - } - - private func sshRouteHopConfiguration( - for host: Host, temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] - ) throws -> SSHRouteHopConfiguration { - guard let identity = identity(for: host) else { throw TermindSSHError.missingCredential } - let username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !username.isEmpty else { throw TermindSSHError.missingUsername } - let expectedHostKeys = try expectedHostKeys( - for: host, temporaryHostKeys: temporaryHostKeys - ) - let resolution = sshCredentials(for: identity) - guard resolution.requiredPassphraseKeyID == nil else { - throw TermindSSHError.keyPassphraseRequired - } - guard !resolution.credentials.isEmpty else { throw TermindSSHError.missingCredential } - return SSHRouteHopConfiguration( - id: host.id, name: host.name, host: host.host, port: host.port, - username: username, credentials: resolution.credentials, - expectedHostKeys: expectedHostKeys, - proxy: try transportProxy(for: host) - ) - } - - private func sshRouteHopConfiguration( - for host: Host, submission: SSHAuthenticationSubmission, - temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] - ) throws -> SSHRouteHopConfiguration { - let username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines) - guard !username.isEmpty else { throw TermindSSHError.missingUsername } - let expectedHostKeys = try expectedHostKeys( - for: host, temporaryHostKeys: temporaryHostKeys - ) - let credentials = try sshCredentials(for: submission) - guard !credentials.isEmpty else { throw TermindSSHError.missingCredential } - return SSHRouteHopConfiguration( - id: host.id, name: host.name, host: host.host, port: host.port, - username: username, credentials: credentials, - expectedHostKeys: expectedHostKeys, - proxy: try transportProxy(for: host) - ) - } - - private func sshCredentials( - for submission: SSHAuthenticationSubmission - ) throws -> [SSHCredentials] { - var credentials: [SSHCredentials] = [] - if let keyID = submission.keyID, let key = key(keyID) { - if key.requiresPassphraseEntry, - submission.passphrase?.isEmpty != false - { - throw TermindSSHError.keyPassphraseRequired - } - if let stored = try keyCredential(for: key) { - credentials.append( - stored.sshCredential( - publicKey: key.publicKey, certificate: key.certificate, - passphraseOverride: submission.passphrase - ) - ) - } - } - if let password = submission.password { credentials.append(.password(password)) } - return credentials - } - - func transportProxy(for host: Host) throws -> SSHTransportProxy? { - guard let proxy = host.proxy else { return nil } - guard proxy.isValid else { - throw TermindSSHError.proxy(String(localized: "The proxy configuration is incomplete.")) - } - var password: String? - if let identityID = proxy.identityID { - guard let identity = identity(identityID), identity.usesPassword else { - throw TermindSSHError.proxy(String(localized: "The proxy password identity is unavailable.")) - } - password = try passwordCredential(for: identity) - guard password != nil else { - throw TermindSSHError.proxy(String(localized: "The proxy password is unavailable.")) - } - } - return SSHTransportProxy( - kind: proxy.kind, hostname: proxy.hostname, port: proxy.port, - username: proxy.username, password: password - ) - } - - /// Loads Key and Password independently so one damaged or missing Keychain - /// item does not suppress the other usable credential. - func sshCredentials(for identity: Identity) -> SSHCredentialResolution { - var credentials: [SSHCredentials] = [] - var failures: [String] = [] - var requiredPassphraseKeyID: SSHKeyRef.ID? - - if let keyID = identity.keyID { - if let key = key(keyID) { - do { - if let credential = try keyCredential(for: key) { - if key.requiresPassphraseEntry { - requiredPassphraseKeyID = key.id - } else { - credentials.append( - credential.sshCredential( - publicKey: key.publicKey, certificate: key.certificate - ) - ) - } - } else { - failures.append( - String(localized: "The selected key has no saved private key.") - ) - } - } catch { - storageError = error.localizedDescription - failures.append(error.localizedDescription) - } - } else { - failures.append( - String(localized: "The selected key is no longer available.") - ) - } - } - - if identity.usesPassword { - do { - if let password = try passwordCredential(for: identity) { - credentials.append(.password(password)) - } else { - failures.append( - String(localized: "The saved password is no longer available.") - ) - } - } catch { - storageError = error.localizedDescription - failures.append(error.localizedDescription) - } - } - - return SSHCredentialResolution( - credentials: credentials, failures: failures, - hasConfiguredCredential: identity.hasSecret, - requiredPassphraseKeyID: requiredPassphraseKeyID - ) - } - - /// Every host tag, deduplicated case-insensitively and sorted. - var allTags: [String] { - uniqueSortedTags(hosts.flatMap(\.tags)) - } - - /// Tags in use on hosts. What the Hosts page offers to filter by: a - /// snippet-only tag there would be a filter that matches nothing. - var hostTags: [String] { - uniqueSortedTags(hosts.flatMap(\.tags)) - } - - private func uniqueSortedTags(_ tags: [String]) -> [String] { - var seen = Set() - let uniqueTags = tags.filter { seen.insert($0.lowercased()).inserted } - return uniqueTags.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } - } - - // MARK: - Search - - /// Also matches the resolved identity, so filtering by `deploy` still finds - /// every box that logs in as `deploy` now that the name lives one hop away. - func searchHosts(_ query: String) -> [Host] { - hosts - .filter { host in - host.matches(query) - || loginAddress(for: host).localizedCaseInsensitiveContains(query) - || (!query.isEmpty && identity(for: host)?.matches(query) == true) - } - .sorted { $0.name < $1.name } - } - - func searchSnippets(_ query: String) -> [Snippet] { - snippets.filter { - $0.matches(query) - || (!query.isEmpty - && snippetPackagePath($0.packageID)?.localizedCaseInsensitiveContains(query) == true) - }.sorted { $0.sortIndex < $1.sortIndex } - } - - func searchKeys(_ query: String) -> [SSHKeyRef] { - keys - .filter { key in - query.isEmpty - || key.label.localizedCaseInsensitiveContains(query) - || key.fingerprint.localizedCaseInsensitiveContains(query) - } - .sorted { manuallyOrdered($0.sortIndex, $0.label, $1.sortIndex, $1.label) } - } - - func searchIdentities(_ query: String) -> [Identity] { - identities.filter { $0.matches(query) }.sorted { - manuallyOrdered($0.sortIndex, $0.name, $1.sortIndex, $1.name) - } - } - - // MARK: - Writes - - func upsert(_ host: Host) { - guard validatedRoute(for: host) != nil else { - storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.") - return - } - if let index = hosts.firstIndex(where: { $0.id == host.id }) { - var updated = host - updated.detectedDistribution = hosts[index].detectedDistribution - hosts[index] = updated - } else { - var new = host - new.sortIndex = nextSortIndex(in: host.groupID) - hosts.append(new) - } - persistCore() - } - - /// Commits one host and its complete editor-owned collections together. - /// - /// `HostEditorView` owns the Host, forwarding rules, and Known Hosts as - /// drafts, so persisting them through separate calls would let Save only - /// partly succeed. The in-memory projections are - /// restored if SwiftData rejects the replacement, and rules whose live - /// connection would no longer match the saved configuration are stopped - /// before their old records disappear. - @discardableResult - func upsert( - _ host: Host, - replacingPortForwardings rules: [PortForwardingRule] - ) -> Bool { - guard validatedRoute(for: host) != nil else { - storageError = String(localized: "The selected jump host creates a routing loop or exceeds 8 hops.") - return false - } - let normalizedRules = rules.map { rule in - var normalized = rule - normalized.hostID = host.id - if normalized.kind == .dynamic { - normalized.targetHost = nil - normalized.targetPort = nil - } - return normalized - } - guard normalizedRules.allSatisfy(\.isValid), - Set(normalizedRules.map(\.id)).count == normalizedRules.count - else { - storageError = String(localized: "One or more forwarding rules are incomplete.") - return false - } - - let oldHosts = hosts - let oldRules = portForwardings - let existingHost = self.host(host.id) - let existingRules = portForwardings(for: host.id) - let nextByID = Dictionary(uniqueKeysWithValues: normalizedRules.map { ($0.id, $0) }) - var rulesToStop = Set( - existingRules.compactMap { old in nextByID[old.id] == old ? nil : old.id } - ) - if let existingHost, existingHost.sshConnectionFields != host.sshConnectionFields { - rulesToStop.formUnion(existingRules.map(\.id)) - } - if !rulesToStop.isEmpty { willDeletePortForwardings?(Array(rulesToStop)) } - - if let index = hosts.firstIndex(where: { $0.id == host.id }) { - var updated = host - updated.detectedDistribution = hosts[index].detectedDistribution - hosts[index] = updated - } else { - var new = host - new.sortIndex = nextSortIndex(in: host.groupID) - hosts.append(new) - } - portForwardings.removeAll { $0.hostID == host.id } - portForwardings.append(contentsOf: normalizedRules) - guard persistCore() else { - hosts = oldHosts - portForwardings = oldRules - return false - } - return true - } - - /// Transitional in-memory projection for repository-owned durable deletion. - /// The repository is the only writer; this keeps legacy views in sync until - /// they consume `HostLibraryModel` directly. - func projectHostDeletion(_ hostID: Host.ID) { - removeHostProjection(hostID) - } - - private func removeHostProjection(_ hostID: Host.ID) { - hosts.removeAll { $0.id == hostID } - for index in hosts.indices where hosts[index].jumpHostID == hostID { - hosts[index].jumpHostID = nil - } - for index in snippets.indices { - snippets[index].targetHostIDs.removeAll { $0 == hostID } - } - portForwardings.removeAll { $0.hostID == hostID } - connectionStatuses = connectionStatuses.filter { $0.value.hostID != hostID } - statuses[hostID] = nil - } - - func setStatus(_ status: ConnectionStatus, for id: Host.ID) { - statuses[id] = status - } - - /// Records one live connection's state and projects terminals and File - /// Transfer browsers for the same host into the status dot shown by Hosts - /// and host pickers. A connected source wins over a connecting or failed - /// sibling, so closing one pane cannot make an otherwise-online host appear - /// offline. - func setConnectionStatus( - _ status: ConnectionStatus, connectionID: UUID, hostID: Host.ID - ) { - if status == .disconnected { - connectionStatuses[connectionID] = nil - } else { - connectionStatuses[connectionID] = (hostID, status) - } - refreshStatus(for: hostID) - } - - /// Persists only a meaningful, validated distribution transition. The - /// current Host projection is mutated in place so a shell result cannot - /// overwrite editor changes made while the connection was starting. - func recordRemoteSystemInfo(_ systemInfo: RemoteSystemInfo?, for hostID: Host.ID) { - guard let index = hosts.firstIndex(where: { $0.id == hostID }), - let systemInfo, let kernelName = systemInfo.kernelName - else { return } - - let next: DetectedDistribution? - if kernelName.caseInsensitiveCompare("Linux") == .orderedSame { - guard let id = systemInfo.distributionID, - let detected = DetectedDistribution( - id: id, likeIDs: systemInfo.distributionLikeIDs - ) - else { return } - next = detected - } else { - next = nil - } - - guard hosts[index].detectedDistribution != next else { return } - hosts[index].detectedDistribution = next - persistCore() - } - - private func refreshStatus(for hostID: Host.ID) { - let active = connectionStatuses.values - .filter { $0.hostID == hostID } - .map(\.status) - if active.contains(.connected) { - statuses[hostID] = .connected - } else if active.contains(.reconnecting) { - statuses[hostID] = .reconnecting - } else if active.contains(.connecting) { - statuses[hostID] = .connecting - } else if let failure = active.first(where: { - if case .failed = $0 { return true } - return false - }) { - statuses[hostID] = failure - } else { - statuses[hostID] = nil - } - } - - /// Drops a pinned host key. The next connection to that host is treated as a - /// first connection, which is the whole point — it is the escape hatch for a - /// server that was legitimately rekeyed. - func forget(knownHost id: KnownHostEntry.ID) { - deleteKnownHosts([id]) - } - - func forgetKnownHosts(for host: Host) { - deleteKnownHosts(Set(knownHosts(for: host).map(\.id))) - } - - func trust(_ entry: KnownHostEntry) { - let endpointMatches: (KnownHostEntry) -> Bool = { - $0.matches(hostname: entry.hostname, port: entry.port) - } - for index in knownHosts.indices where endpointMatches(knownHosts[index]) { - knownHosts[index].isMismatched = false - } - if let index = knownHosts.firstIndex(where: { - endpointMatches($0) && $0.fingerprint == entry.fingerprint - }) { - let retainedID = knownHosts[index].id - knownHosts[index].algorithm = entry.algorithm - knownHosts[index].addedAt = entry.addedAt - knownHosts.removeAll { - $0.id != retainedID && endpointMatches($0) - && $0.fingerprint == entry.fingerprint - } - } else { - knownHosts.append(entry) - } - persistCore() - } - - func markKnownHostMismatched(hostname: String, port: Int) { - var changed = false - for index in knownHosts.indices - where knownHosts[index].matches(hostname: hostname, port: port) { - knownHosts[index].isMismatched = true - changed = true - } - if changed { persistCore() } - } - - func setExpanded(_ expanded: Bool, for groupID: HostGroup.ID) { - guard let index = groups.firstIndex(where: { $0.id == groupID }) else { return } - groups[index].isExpanded = expanded - persistCore() - } - - func setUngroupedExpanded(_ expanded: Bool) { - isUngroupedExpanded = expanded - } - - func setSnippetPackageExpanded(_ expanded: Bool, for packageID: SnippetPackage.ID) { - guard let index = snippetPackages.firstIndex(where: { $0.id == packageID }) else { return } - snippetPackages[index].isExpanded = expanded - persistCore() - } - - func setSnippetsUngroupedExpanded(_ expanded: Bool) { - isSnippetsUngroupedExpanded = expanded - } - - /// Adds or updates an identity. New ones land at the end of the list, which - /// is sorted by name wherever it is shown, so there is no order to keep. - @discardableResult - func upsert(_ identity: Identity) -> Bool { - if let index = identities.firstIndex(where: { $0.id == identity.id }) { - var updated = identity - updated.sortIndex = identities[index].sortIndex - identities[index] = updated - } else { - var new = identity - new.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1 - identities.append(new) - } - if !identity.isProxyEligible { - for index in hosts.indices where hosts[index].proxy?.identityID == identity.id { - hosts[index].proxy?.identityID = nil - hosts[index].proxy?.username = "" - } - } - return persistCore() - } - - /// Updates an Identity and its Keychain password as one operation. - /// - /// The editor supplies intent rather than an optional string so a blank - /// field can keep an existing password while an explicit remove action can - /// delete it. The old reference is resolved here, before the model changes, - /// which prevents a cleared Identity from making its former Keychain item - /// unreachable. - func upsert(_ identity: Identity, passwordUpdate: IdentityPasswordUpdate) throws { - let previousIdentity = self.identity(identity.id) - let previousReference = previousIdentity?.credentialRef ?? identity.credentialRef - let previousStorage = previousIdentity?.passwordStorage ?? identity.passwordStorage - var updated = identity - - let oldPassword = - previousReference.isEmpty - ? nil : try keychain.string(for: previousReference, storage: previousStorage) - let identitySnapshot = identities - let hostSnapshot = hosts - var wroteDestination = false - do { - switch passwordUpdate { - case .keep: - updated.credentialRef = previousReference - if previousStorage != updated.passwordStorage, !previousReference.isEmpty { - guard let oldPassword else { - throw TermindSSHError.library( - String( - localized: - "The saved password is unavailable on this Mac. Enter it again before changing storage." - ) - ) - } - try keychain.setString( - oldPassword, for: previousReference, storage: updated.passwordStorage - ) - wroteDestination = true - } - case .replace(let password): - guard !password.isEmpty else { - throw TermindSSHError.library( - String(localized: "A replacement password cannot be empty.") - ) - } - let reference = - previousReference.isEmpty - ? "termind.password.\(UUID().uuidString)" - : previousReference - try keychain.setString(password, for: reference, storage: updated.passwordStorage) - wroteDestination = true - updated.credentialRef = reference - case .remove: - updated.credentialRef = "" - } - storageError = nil - } catch { - storageError = error.localizedDescription - throw error - } - - guard upsert(updated) else { - identities = identitySnapshot - hosts = hostSnapshot - do { - if wroteDestination { - if updated.passwordStorage == previousStorage, let oldPassword { - try keychain.setString( - oldPassword, for: previousReference, storage: previousStorage - ) - } else if !updated.credentialRef.isEmpty { - try keychain.removePassword( - updated.credentialRef, storage: updated.passwordStorage - ) - } - } - } catch { - storageError = String( - localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" - ) - } - throw TermindSSHError.library( - storageError ?? String(localized: "The identity could not be saved.") - ) - } - - let removesPrevious = - !previousReference.isEmpty - && (updated.credentialRef.isEmpty || previousStorage != updated.passwordStorage) - if removesPrevious { - do { - try keychain.removePassword(previousReference, storage: previousStorage) - } catch { - // The newly committed destination stays authoritative. Retaining - // the source is safer than rolling a successful save backward. - storageError = String( - localized: - "The password was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)" - ) - } - } - } - - /// Throwing counterpart used by connection setup, where a Keychain failure - /// must remain distinguishable from an Identity that has no password. - func passwordCredential(for identity: Identity) throws -> String? { - guard identity.usesPassword else { return nil } - return try keychain.string( - for: identity.credentialRef, storage: identity.passwordStorage - ) - } - - func passwordAvailability(for identity: Identity) -> SecretAvailability { - guard identity.usesPassword else { return .notConfigured } - do { - return try passwordCredential(for: identity) == nil ? .missing : .available - } catch { - return .failed(error.localizedDescription) - } - } - - func isProxyEligibleOnThisDevice(_ identity: Identity) -> Bool { - identity.isProxyEligible && passwordAvailability(for: identity) == .available - } - - /// Removes an identity, along with the password it was the only way to - /// reach, and unfiles it from every host that logged in with it — those - /// hosts keep their address and go back to "no login chosen yet". - @discardableResult - func delete(identityID: Identity.ID) -> Bool { - guard let identity = identity(identityID) else { return false } - let password: String? - do { - password = - identity.credentialRef.isEmpty - ? nil - : try keychain.string( - for: identity.credentialRef, storage: identity.passwordStorage - ) - try keychain.removePassword( - identity.credentialRef, storage: identity.passwordStorage - ) - } catch { - storageError = error.localizedDescription - return false - } - let identitySnapshot = identities - let hostSnapshot = hosts - identities.removeAll { $0.id == identityID } - for index in hosts.indices where hosts[index].identityID == identityID { - hosts[index].identityID = nil - } - for index in hosts.indices where hosts[index].proxy?.identityID == identityID { - hosts[index].proxy?.identityID = nil - hosts[index].proxy?.username = "" - } - guard persistCore() else { - identities = identitySnapshot - hosts = hostSnapshot - if let password { - do { - try keychain.setString( - password, for: identity.credentialRef, - storage: identity.passwordStorage - ) - } catch { - storageError = String( - localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" - ) - } - } - return false - } - return true - } - - func upsert(_ key: SSHKeyRef, credential: StoredSSHKeyCredential? = nil) throws { - let previousKey = self.key(key.id) - if key.storage == .secureEnclave, !key.certificate.isEmpty { - throw TermindSSHError.library( - String(localized: "Secure Enclave keys cannot store an SSH certificate.") - ) - } - if !key.certificate.isEmpty { - _ = try SSHUserCertificate.parse(key.certificate, matching: key.publicKey) - } - let keySnapshot = keys - let changesLocation = - previousKey.map { - $0.storage != key.storage || $0.credentialRef != key.credentialRef - } ?? false - if changesLocation, - previousKey?.storage == .secureEnclave || key.storage == .secureEnclave - { - throw TermindSSHError.library( - String(localized: "Secure Enclave keys cannot be moved to another storage location.") - ) - } - let previousCredential = - try (credential != nil || changesLocation) - ? previousKey.flatMap { try keyCredential(for: $0) } - : nil - let destinationCredential = credential ?? (changesLocation ? previousCredential : nil) - if let destinationCredential { - try keychain.setKeyCredential( - destinationCredential, for: key.credentialRef, storage: key.storage - ) - } - if let index = keys.firstIndex(where: { $0.id == key.id }) { - var updated = key - updated.sortIndex = keys[index].sortIndex - keys[index] = updated - } else { - var new = key - new.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1 - keys.append(new) - } - guard persistCore() else { - keys = keySnapshot - do { - if changesLocation, destinationCredential != nil { - try keychain.removeKeyCredential( - key.credentialRef, storage: key.storage - ) - } else if destinationCredential != nil, let previousKey, let previousCredential { - try keychain.setKeyCredential( - previousCredential, for: previousKey.credentialRef, - storage: previousKey.storage - ) - } else if destinationCredential != nil { - try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) - } - } catch { - storageError = String( - localized: "The database save and Keychain rollback both failed: \(error.localizedDescription)" - ) - } - throw TermindSSHError.library( - storageError ?? String(localized: "The key could not be saved.") - ) - } - - if changesLocation, let previousKey { - do { - try keychain.removeKeyCredential( - previousKey.credentialRef, storage: previousKey.storage - ) - } catch { - storageError = String( - localized: - "The key was saved, but its previous Keychain copy could not be removed: \(error.localizedDescription)" - ) - } - } - } - - /// Reorders Keys without allowing them to leave their fixed Keychain - /// section. The supplied order is the order of a multi-card drag. - func move(keyIDs: [SSHKeyRef.ID], before next: SSHKeyRef.ID? = nil) { - guard - reorderManually( - keyIDs, before: next, items: &keys, sortIndex: \SSHKeyRef.sortIndex - ) - else { return } - persistCore() - } - - /// Reorders Identities independently from Keys. A mixed Keychain selection - /// is split by the page before it reaches this fixed-section operation. - func move(identityIDs: [Identity.ID], before next: Identity.ID? = nil) { - guard - reorderManually( - identityIDs, before: next, items: &identities, - sortIndex: \Identity.sortIndex - ) - else { return } - persistCore() - } - - func keyCredential(for key: SSHKeyRef) throws -> StoredSSHKeyCredential? { - try keychain.keyCredential(for: key.credentialRef, storage: key.storage) - } - - func keyAvailability(for key: SSHKeyRef) -> SecretAvailability { - do { - return try keyCredential(for: key) == nil ? .missing : .available - } catch { - return .failed(error.localizedDescription) - } - } - - func delete(keyID: SSHKeyRef.ID) throws { - guard let key = key(keyID) else { return } - let credential = try keyCredential(for: key) - try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) - let keySnapshot = keys - let identitySnapshot = identities - keys.removeAll { $0.id == keyID } - for index in identities.indices where identities[index].keyID == keyID { - identities[index].keyID = nil - } - guard persistCore() else { - keys = keySnapshot - identities = identitySnapshot - if let credential { - try keychain.setKeyCredential( - credential, for: key.credentialRef, storage: key.storage - ) - } - throw TermindSSHError.library( - storageError ?? String(localized: "The key could not be deleted.") - ) - } - } - - /// Adds or updates a group. A new group is placed after its last sibling - /// rather than at the end of the flat array — `sortIndex` is per-parent, so - /// a child of Production has to be numbered against Production's children. - func upsert(_ group: HostGroup) { - if let index = groups.firstIndex(where: { $0.id == group.id }) { - groups[index] = group - } else { - var new = group - new.sortIndex = nextSortIndex(under: group.parentID) - groups.append(new) - } - persistCore() - } - - /// Deletes a group and every group beneath it. - /// - /// Removes a group subtree after callers have durably requested deletion - /// for any hosts they do not intend to keep. - func deleteGroupProjection(_ groupID: HostGroup.ID) { - let doomed = Set([groupID] + descendantGroups(of: groupID).map(\.id)) - for index in hosts.indices where hosts[index].groupID.map(doomed.contains) ?? false { - hosts[index].groupID = nil - } - groups.removeAll { doomed.contains($0.id) } - persistCore() - } - - func upsert(_ snippet: Snippet) { - if let index = snippets.firstIndex(where: { $0.id == snippet.id }) { - var updated = snippet - if snippets[index].packageID == updated.packageID { - updated.sortIndex = snippets[index].sortIndex - } else { - updated.sortIndex = nextSnippetSortIndex(in: updated.packageID) - } - snippets[index] = updated - } else { - var new = snippet - new.sortIndex = nextSnippetSortIndex(in: snippet.packageID) - snippets.append(new) - } - persistCore() - } - - func delete(snippetIDs: [Snippet.ID]) { - let doomed = Set(snippetIDs) - snippets.removeAll { doomed.contains($0.id) } - persistCore() - } - - func upsert(_ package: SnippetPackage) { - if let index = snippetPackages.firstIndex(where: { $0.id == package.id }) { - let forbidden = Set( - [package.id] + descendantSnippetPackages(of: package.id).map(\.id) - ) - guard package.parentID.map(forbidden.contains) != true else { return } - var updated = package - if snippetPackages[index].parentID != updated.parentID { - updated.sortIndex = nextSnippetPackageSortIndex(under: updated.parentID) - } - snippetPackages[index] = updated - } else { - guard package.parentID != package.id else { return } - var new = package - new.sortIndex = nextSnippetPackageSortIndex(under: package.parentID) - snippetPackages.append(new) - } - expandSnippetPackageAncestors(package.id) - persistCore() - } - - func delete(snippetPackageID: SnippetPackage.ID, keepSnippets: Bool) { - let doomed = Set( - [snippetPackageID] + descendantSnippetPackages(of: snippetPackageID).map(\.id) - ) - if keepSnippets { - for index in snippets.indices where snippets[index].packageID.map(doomed.contains) == true { - snippets[index].packageID = nil - snippets[index].sortIndex = nextSnippetSortIndex(in: nil) - } - } else { - snippets.removeAll { $0.packageID.map(doomed.contains) == true } - } - snippetPackages.removeAll { doomed.contains($0.id) } - persistCore() - } - - func move( - snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?, - before next: Snippet.ID? = nil - ) { - if let packageID, snippetPackage(packageID) == nil { return } - guard - let placement = manualOrderPlacement( - snippetIDs, before: next, - destinationItems: snippets(in: packageID), - validIDs: Set(snippets.map(\.id)), sortIndex: \Snippet.sortIndex - ) - else { return } - if placement.moving.allSatisfy({ snippet($0)?.packageID == packageID }) - && snippets(in: packageID).map(\.id) == placement.resulting - { - return - } - - let moving = Set(placement.moving) - for index in snippets.indices { - if moving.contains(snippets[index].id) { snippets[index].packageID = packageID } - if let position = placement.positions[snippets[index].id] { - snippets[index].sortIndex = position - } - } - expandSnippetPackageAncestors(packageID) - persistCore() - } - - func upsert(_ forwarding: PortForwardingRule) { - guard forwarding.isValid, host(forwarding.hostID) != nil else { return } - var normalized = forwarding - if normalized.kind == .dynamic { - normalized.targetHost = nil - normalized.targetPort = nil - } - if let index = portForwardings.firstIndex(where: { $0.id == normalized.id }) { - portForwardings[index] = normalized - } else { - portForwardings.append(normalized) - } - persistCore() - } - - /// Root jump first and destination last. A nil result means the proposed - /// host closes a cycle, references a missing jump, or exceeds eight hosts. - func validatedRoute(for proposed: Host) -> [Host]? { - var route: [Host] = [] - var cursor: Host? = proposed - var visited: Set = [] - while let current = cursor { - guard visited.insert(current.id).inserted, route.count < 8 else { return nil } - route.append(current) - guard let jumpID = current.jumpHostID else { break } - if jumpID == proposed.id { return nil } - cursor = jumpID == proposed.id ? proposed : host(jumpID) - if cursor == nil { return nil } - } - return route.reversed() - } - - func route(for host: Host) -> [Host] { - validatedRoute(for: host) ?? [host] - } - - func routeSummary(for host: Host) -> String { - route(for: host).map { node in - if let proxy = node.proxy { - return "\(proxy.kind.displayName) \(proxy.hostname):\(proxy.port) → \(node.displayAddress)" - } - return node.displayAddress - }.joined(separator: " → ") - } - - /// OpenSSH equivalent when the route needs no in-process Proxy. Passwords, - /// private keys, and proxy credentials never enter the clipboard. - func sshCommand(for host: Host) -> String? { - sshCommand(for: host, additionalArguments: []) - } - - func sshCommand(for rule: PortForwardingRule, host proposedHost: Host? = nil) -> String? { - guard let destination = proposedHost ?? host(rule.hostID), destination.id == rule.hostID - else { return nil } - var arguments = ["-N"] - switch rule.kind { - case .local: - guard let host = rule.targetHost, let port = rule.targetPort else { return nil } - arguments += ["-L", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"] - case .remote: - guard let host = rule.targetHost, let port = rule.targetPort else { return nil } - arguments += ["-R", "\(rule.bindAddress):\(Self.commandAddress(host, port: port))"] - case .dynamic: - arguments += ["-D", rule.bindAddress] - } - return sshCommand(for: destination, additionalArguments: arguments) - } - - private func sshCommand(for destination: Host, additionalArguments: [String]) -> String? { - guard let route = validatedRoute(for: destination), - route.allSatisfy({ $0.proxy == nil }) - else { return nil } - - let jumps = route.dropLast().map { node in - Self.openSSHAddress( - host: node.host, port: node.port, - username: identity(for: node)?.username, bracketsIPv6: true - ) - } - var arguments = ["ssh"] + additionalArguments - if !jumps.isEmpty { arguments += ["-J", jumps.joined(separator: ",")] } - if destination.port != 22 { arguments += ["-p", String(destination.port)] } - arguments.append( - Self.openSSHAddress( - host: destination.host, port: 22, - username: identity(for: destination)?.username, bracketsIPv6: false - ) - ) - return arguments.map(Self.shellQuoted).joined(separator: " ") - } - - private static func openSSHAddress( - host: String, port: Int, username: String?, bracketsIPv6: Bool - ) -> String { - let trimmedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedHost = bracketsIPv6 && host.contains(":") ? "[\(host)]" : host - let address = port == 22 ? resolvedHost : "\(resolvedHost):\(port)" - guard let trimmedUsername, !trimmedUsername.isEmpty else { return address } - return "\(trimmedUsername)@\(address)" - } - - /// POSIX-shell quoting for a single argument. Common SSH punctuation stays - /// readable; everything else uses single quotes without losing apostrophes. - private static func shellQuoted(_ value: String) -> String { - let safe = CharacterSet( - charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_@%+=:,./-") - if !value.isEmpty, value.unicodeScalars.allSatisfy(safe.contains) { return value } - return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" - } - - private static func commandAddress(_ host: String, port: Int) -> String { - "\(host.contains(":") ? "[\(host)]" : host):\(port)" - } - - func setSFTPTransferState(_ state: SFTPTransfer.State, for id: SFTPTransfer.ID) { - guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return } - sftpTransfers[index].state = state - } - - func enqueueSFTPTransfer(_ sftpTransfer: SFTPTransfer) { - sftpTransfers.append(sftpTransfer) - } - - func sftpTransfer(_ id: SFTPTransfer.ID) -> SFTPTransfer? { - sftpTransfers.first { $0.id == id } - } - - func updateSFTPTransfer( - _ id: SFTPTransfer.ID, completedBytes: Int64, totalBytes: Int64, - bytesPerSecond: Int64 - ) { - guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return } - sftpTransfers[index].transferredBytes = completedBytes - sftpTransfers[index].byteCount = totalBytes - sftpTransfers[index].bytesPerSecond = bytesPerSecond - } - - func finishSFTPTransfer(_ id: SFTPTransfer.ID, state: SFTPTransfer.State) { - guard let index = sftpTransfers.firstIndex(where: { $0.id == id }) else { return } - sftpTransfers[index].state = state - sftpTransfers[index].bytesPerSecond = 0 - sftpTransfers[index].completedAt = Date() - if state == .completed { - sftpTransfers[index].transferredBytes = sftpTransfers[index].byteCount - } - } - - /// Clears terminal transfer records while leaving queued and running work - /// untouched. The transfer center is a current-session history, not a log. - func clearFinishedSFTPTransfers() { - sftpTransfers.removeAll { - switch $0.state { - case .completed, .cancelled, .failed: true - case .queued, .running, .paused: false - } - } - } - - /// Files one or more hosts into a group, preserving the order supplied. - /// - /// `next` is the host that should follow the moved batch; `nil` appends it. - /// Updating every host before persisting makes a multi-selection one logical - /// drag instead of a series of partially saved moves. - func move(hostIDs: [Host.ID], into group: HostGroup.ID?, before next: Host.ID? = nil) { - if let group, self.group(group) == nil { return } - guard - let placement = manualOrderPlacement( - hostIDs, before: next, - destinationItems: hosts(in: group), - validIDs: Set(hosts.map(\.id)), - sortIndex: \Host.sortIndex - ) - else { return } - if placement.moving.allSatisfy({ host($0)?.groupID == group }) - && hosts(in: group).map(\.id) == placement.resulting - { - return - } - - let movingSet = Set(placement.moving) - for index in hosts.indices { - if movingSet.contains(hosts[index].id) { hosts[index].groupID = group } - if let value = placement.positions[hosts[index].id] { - hosts[index].sortIndex = value - } - } - persistCore() - } - - @discardableResult - func saveKnownHost(_ entry: KnownHostEntry) -> Bool { - var entry = entry - entry.hostname = entry.hostname.trimmingCharacters(in: .whitespacesAndNewlines) - entry.fingerprint = entry.fingerprint.trimmingCharacters(in: .whitespacesAndNewlines) - guard entry.isValid, - !knownHosts.contains(where: { - $0.id != entry.id && $0.hostname == entry.hostname && $0.port == entry.port - && $0.identity == entry.identity - }) - else { - storageError = String(localized: "One or more Known Hosts entries are incomplete or duplicated.") - return false - } - let previous = knownHosts - if let index = knownHosts.firstIndex(where: { $0.id == entry.id }) { - // Editing the address or key explicitly creates a new trust decision. - if knownHosts[index].identity != entry.identity || knownHosts[index].hostname != entry.hostname - || knownHosts[index].port != entry.port - { - entry.addedAt = Date() - entry.isMismatched = false - } - knownHosts[index] = entry - } else { - entry.addedAt = Date() - knownHosts.append(entry) - } - guard persistCore() else { - knownHosts = previous - return false - } - return true - } - - @discardableResult - func deleteKnownHosts(_ ids: Set) -> Bool { - let previous = knownHosts - knownHosts.removeAll { ids.contains($0.id) } - guard persistCore() else { - knownHosts = previous - return false - } - return true - } - - /// New records only. Existing credentials and host configurations are never overwritten. - func commitSSHImport(_ batch: SSHImportBatch) throws { - let oldHosts = hosts - let oldIdentities = identities - let oldKeys = keys - let oldKnownHosts = knownHosts - guard Set(batch.hosts.map(\.id)).isDisjoint(with: hosts.map(\.id)), - Set(batch.keys.map(\.id)).isDisjoint(with: keys.map(\.id)), - Set(batch.identities.map(\.id)).isDisjoint(with: identities.map(\.id)), - batch.knownHosts.allSatisfy(\.isValid) - else { throw CocoaError(.validationMultipleErrors) } - var written: [SSHKeyRef] = [] - do { - for key in batch.keys { - guard let secret = batch.credentials[key.id] else { - throw CocoaError(.validationMissingMandatoryProperty) - } - try keychain.setKeyCredential(secret, for: key.credentialRef, storage: key.storage) - written.append(key) - } - for var key in batch.keys { - key.sortIndex = (keys.map(\.sortIndex).max() ?? -1) + 1 - keys.append(key) - } - for var identity in batch.identities { - identity.sortIndex = (identities.map(\.sortIndex).max() ?? -1) + 1 - identities.append(identity) - } - for var host in batch.hosts { - host.sortIndex = nextSortIndex(in: nil) - hosts.append(host) - } - guard batch.hosts.allSatisfy({ validatedRoute(for: $0) != nil }) else { - throw CocoaError(.validationMultipleErrors) - } - knownHosts.append(contentsOf: batch.knownHosts) - guard persistCore() else { - throw TermindSSHError.library(storageError ?? String(localized: "Import failed.")) - } - } catch { - hosts = oldHosts - identities = oldIdentities - keys = oldKeys - knownHosts = oldKnownHosts - var cleanupFailed = false - for key in written { - do { try keychain.removeKeyCredential(key.credentialRef, storage: key.storage) } catch { - cleanupFailed = true - } - } - if cleanupFailed { - storageError = String( - localized: "Import failed. Some newly stored keys could not be removed from Keychain.") - throw TermindSSHError.library(storageError!) - } - throw error - } - } - - private func nextSortIndex(in group: HostGroup.ID?) -> Double { - (hosts(in: group).last?.sortIndex ?? 0) + 1 - } - - /// The same, for a group among its siblings. Separate from the hosts version - /// because the two number different collections: a new child of Production is - /// placed after Production's last *group*, and the hosts filed directly in - /// Production have their own independent run of indices. - private func nextSortIndex(under parent: HostGroup.ID?) -> Double { - let siblings = parent.map { childGroups(of: $0) } ?? rootGroups - return (siblings.last?.sortIndex ?? 0) + 1 - } - - private func nextSnippetSortIndex(in package: SnippetPackage.ID?) -> Double { - (snippets(in: package).last?.sortIndex ?? -1) + 1 - } - - private func nextSnippetPackageSortIndex(under parent: SnippetPackage.ID?) -> Double { - let siblings = parent.map { childSnippetPackages(of: $0) } ?? rootSnippetPackages - return (siblings.last?.sortIndex ?? -1) + 1 - } - - private func expandSnippetPackageAncestors(_ packageID: SnippetPackage.ID?) { - var current = packageID - var visited: Set = [] - while let id = current, let index = snippetPackages.firstIndex(where: { $0.id == id }), - visited.insert(id).inserted - { - snippetPackages[index].isExpanded = true - current = snippetPackages[index].parentID - } - } - - @discardableResult - private func persistCore() -> Bool { - do { - try vault.save( - VaultSnapshot( - hosts: hosts, groups: groups, identities: identities, - keys: keys, knownHosts: knownHosts, snippets: snippets, - snippetPackages: snippetPackages, portForwardings: portForwardings - )) - storageError = nil - return true - } catch { - storageError = error.localizedDescription - return false - } - } - - /// Applies a CloudKit-imported projection without writing it back as a new - /// local edit. Runtime connection and transfer state intentionally remain. - func replacePersistedSnapshot(_ snapshot: VaultSnapshot) { - hosts = snapshot.hosts - groups = snapshot.groups - identities = normalizedManualOrder( - snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name - ) - keys = normalizedManualOrder( - snapshot.keys, sortIndex: \SSHKeyRef.sortIndex, name: \SSHKeyRef.label - ) - knownHosts = snapshot.knownHosts - snippets = normalizedSnippetOrder(snapshot.snippets) - snippetPackages = snapshot.snippetPackages - portForwardings = snapshot.portForwardings - statuses = statuses.filter { id, _ in hosts.contains { $0.id == id } } - connectionStatuses = connectionStatuses.filter { _, value in - hosts.contains { $0.id == value.hostID } - } - storageError = nil - vault.didApplyRemoteSnapshot() - } -} - -extension Host { - fileprivate var sshConnectionFields: SSHConnectionFields { - SSHConnectionFields( - host: host, port: port, identityID: identityID, - jumpHostID: jumpHostID, proxy: proxy - ) - } -} - -private struct SSHConnectionFields: Equatable { - let host: String - let port: Int - let identityID: Identity.ID? - let jumpHostID: Host.ID? - let proxy: HostProxy? -} - -// MARK: - Manual ordering - -/// A deterministic tie-break keeps hand-built preview data usable before its -/// first move. Persisted data normally has unique -/// fractional indices, so the name is not otherwise part of the ordering. -private func manuallyOrdered( - _ lhsIndex: Double, _ lhsName: String, - _ rhsIndex: Double, _ rhsName: String -) -> Bool { - if lhsIndex != rhsIndex { return lhsIndex < rhsIndex } - return lhsName.localizedCaseInsensitiveCompare(rhsName) == .orderedAscending -} - -/// Normalize duplicate preview and unit-test defaults once so their first -/// fractional move has real alphabetical neighbours. -private func normalizedManualOrder( - _ values: [Item], - sortIndex: WritableKeyPath, - name: KeyPath -) -> [Item] { - let indices = values.map { $0[keyPath: sortIndex] } - guard indices.contains(where: { !$0.isFinite }) || Set(indices).count != indices.count else { - return values - } - - var ordered = values.sorted { - manuallyOrdered( - $0[keyPath: sortIndex], $0[keyPath: name], - $1[keyPath: sortIndex], $1[keyPath: name] - ) - } - for index in ordered.indices { ordered[index][keyPath: sortIndex] = Double(index) } - return ordered -} - -/// Snippet indices are local to a Package. Equal indices in two different -/// Packages are expected, so normalization must never compare them globally. -private func normalizedSnippetOrder(_ values: [Snippet]) -> [Snippet] { - var result = values - for packageID in Set(values.map(\.packageID)) { - let members = result.indices.filter { result[$0].packageID == packageID } - let sortIndices = members.map { result[$0].sortIndex } - guard - sortIndices.contains(where: { !$0.isFinite }) - || Set(sortIndices).count != sortIndices.count - else { continue } - - let ordered = members.sorted { - manuallyOrdered( - result[$0].sortIndex, result[$0].title, - result[$1].sortIndex, result[$1].title - ) - } - for (position, index) in ordered.enumerated() { - result[index].sortIndex = Double(position) - } - } - return result -} - -private struct ManualOrderPlacement { - let moving: [ID] - let resulting: [ID] - let positions: [ID: Double] -} - -/// Generic remove-then-insert calculation shared by Hosts, Keys and Identities. -/// The destination may omit moving IDs (Hosts crossing groups), so validity is -/// supplied separately from the concrete destination collection. -private func manualOrderPlacement( - _ requestedIDs: [Item.ID], - before next: Item.ID?, - destinationItems: [Item], - validIDs: Set, - sortIndex: KeyPath -) -> ManualOrderPlacement? where Item.ID: Hashable { - var seen: Set = [] - let moving = requestedIDs.filter { - validIDs.contains($0) && seen.insert($0).inserted - } - guard !moving.isEmpty else { return nil } - - let movingSet = Set(moving) - let remaining = destinationItems.filter { !movingSet.contains($0.id) } - let insertionIndex: Int - if let next { - guard let index = remaining.firstIndex(where: { $0.id == next }) else { return nil } - insertionIndex = index - } else { - insertionIndex = remaining.endIndex - } - - let resulting = - Array(remaining[.. 0 ? remaining[insertionIndex - 1][keyPath: sortIndex] : nil - let upper = insertionIndex < remaining.endIndex ? remaining[insertionIndex][keyPath: sortIndex] : nil - var positions: [Item.ID: Double] = [:] - - switch (lower, upper) { - case (let lower?, let upper?): - let step = (upper - lower) / Double(moving.count + 1) - if step.isFinite && step > 0 { - for (offset, id) in moving.enumerated() { - positions[id] = lower + step * Double(offset + 1) - } - } - case (let lower?, nil): - for (offset, id) in moving.enumerated() { - positions[id] = lower + Double(offset + 1) - } - case (nil, let upper?): - for (offset, id) in moving.enumerated() { - positions[id] = upper - Double(moving.count - offset) - } - case (nil, nil): - for (offset, id) in moving.enumerated() { positions[id] = Double(offset) } - } - - if positions.count != moving.count { - positions = Dictionary( - uniqueKeysWithValues: resulting.enumerated().map { ($0.element, Double($0.offset)) } - ) - } - return ManualOrderPlacement(moving: moving, resulting: resulting, positions: positions) -} - -/// Shared fixed-section wrapper for every manually ordered Keychain collection. -private func reorderManually( - _ requestedIDs: [Item.ID], - before next: Item.ID?, - items: inout [Item], - sortIndex: WritableKeyPath -) -> Bool where Item.ID: Hashable { - let current = items.sorted { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] } - guard - let placement = manualOrderPlacement( - requestedIDs, before: next, destinationItems: current, - validIDs: Set(current.map(\.id)), sortIndex: sortIndex - ), placement.resulting != current.map(\.id) - else { return false } - - for index in items.indices { - if let value = placement.positions[items[index].id] { - items[index][keyPath: sortIndex] = value - } - } - items.sort { $0[keyPath: sortIndex] < $1[keyPath: sortIndex] } - return true -} +typealias HostLibraryModel = TermindApplication.HostLibraryModel diff --git a/TFM/Application/FeatureModels/KnownHostsLibraryModel.swift b/TFM/Application/FeatureModels/KnownHostsLibraryModel.swift index 7211b77e..766cde32 100644 --- a/TFM/Application/FeatureModels/KnownHostsLibraryModel.swift +++ b/TFM/Application/FeatureModels/KnownHostsLibraryModel.swift @@ -1,20 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -@Observable -final class KnownHostsLibraryModel { - private let library: HostLibraryModel - init(library: HostLibraryModel) { self.library = library } - var entries: [KnownHostEntry] { library.knownHosts.sorted { $0.addedAt > $1.addedAt } } - var storageError: String? { library.storageError } - func entry(_ id: UUID) -> KnownHostEntry? { entries.first { $0.id == id } } - func search(_ query: String) -> [KnownHostEntry] { - entries.filter { - query.isEmpty || $0.displayAddress.localizedCaseInsensitiveContains(query) - || $0.fingerprint.localizedCaseInsensitiveContains(query) - || $0.algorithm.rawValue.localizedCaseInsensitiveContains(query) - } - } - func save(_ entry: KnownHostEntry) -> Bool { library.saveKnownHost(entry) } - func delete(_ ids: Set) -> Bool { library.deleteKnownHosts(ids) } -} +typealias KnownHostsLibraryModel = TermindApplication.KnownHostsLibraryModel diff --git a/TFM/Application/FeatureModels/SSHImportModel.swift b/TFM/Application/FeatureModels/SSHImportModel.swift index 182eac37..82adf757 100644 --- a/TFM/Application/FeatureModels/SSHImportModel.swift +++ b/TFM/Application/FeatureModels/SSHImportModel.swift @@ -1,403 +1,10 @@ import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host +typealias SSHImportModel = TermindApplication.SSHImportModel -@Observable -final class SSHImportModel { - var items: [SSHImportItem] = [] - var expanded = Set(SSHImportCategory.allCases) - var storage: KeyStorage = .iCloud - private(set) var isWorking = false - private(set) var error: String? - private(set) var completed = false - private(set) var importedCount = 0 - private let library: HostLibraryModel - private let reader: any SSHImportReading - private var root: URL? - private var grants: [URL] = [] - var sourcePath: String? { root?.path } - private var knownHostsOnly = false - private var jumpIDs: [String: UUID] = [:] - - init(library: HostLibraryModel, reader: any SSHImportReading) { - self.library = library - self.reader = reader - } - - func scan(_ url: URL, knownHostsOnly: Bool) async { - guard !isWorking else { return } - grants = [] - items = [] - completed = false - root = url - self.knownHostsOnly = knownHostsOnly - await reload() - } - - func authorize(_ url: URL) async { - grants.append(url) - await reload() - } - - private func reload() async { - guard let root else { return } - isWorking = true - error = nil - defer { isWorking = false } - do { - let previous = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) - items = try await reader.scan(root: root, knownHostsOnly: knownHostsOnly, grants: grants) - addJumpDestinations() - for index in items.indices { - if let old = previous[items[index].id] { - items[index].selected = old.selected - items[index].acceptedWarnings = old.acceptedWarnings - items[index].preferredKeyID = old.preferredKeyID - if items[index].host != nil { items[index].name = old.name } - } - } - refreshDuplicates() - } catch { - self.error = - (error as? SSHImportSourceError)?.localizedDescription - ?? String(localized: "The SSH files could not be read.") - } - } - - private func addJumpDestinations() { - let originalIDs = items.filter { $0.host != nil }.map(\.id) - let jumps = items.compactMap(\.host).flatMap(\.jumps) - for jump in jumps where !items.contains(where: { $0.host?.alias == jump }) { - let userParts = jump.split(separator: "@", maxSplits: 1).map(String.init) - let address = userParts.last ?? jump - let host: String - let port: Int? - if address.hasPrefix("["), let endpoint = SSHImportParser.endpoint(address) { - (host, port) = endpoint - } else if address.filter({ $0 == ":" }).count == 1 { - let parts = address.split(separator: ":").map(String.init) - host = parts.first ?? address - port = parts.count == 2 ? Int(parts[1]) ?? 0 : 0 - } else { - host = address - port = nil - } - let saved = library.hosts.first { $0.name == host } - let identity = saved?.identityID.flatMap(library.identity) - let base = items.first { $0.host?.alias == host }?.host - let value = SSHImportedHost( - alias: jump, hostname: base?.hostname ?? saved?.host ?? host, - port: port ?? base?.port ?? saved?.port ?? 22, - username: userParts.count == 2 ? userParts[0] : base?.username ?? identity?.username ?? NSUserName(), - identityPaths: base?.identityPaths ?? [], existingKeyID: identity?.keyID) - let id = jumpIDs[jump] ?? UUID() - jumpIDs[jump] = id - items.append(SSHImportItem(id: id, category: .hosts, name: jump, source: "ProxyJump", host: value)) - } - // A multi-hop route must not rewrite the standalone configuration of its intermediate aliases. - for id in originalIDs { - guard let index = items.firstIndex(where: { $0.id == id }), - let chain = items[index].host?.jumps, chain.count > 1 - else { continue } - var previous = chain[0] - for alias in chain.dropFirst() { - guard let base = items.first(where: { $0.host?.alias == alias }), var host = base.host else { continue } - let routeAlias = previous + " → " + alias - if !items.contains(where: { $0.host?.alias == routeAlias }) { - host.alias = routeAlias - host.jumps = [previous] - let id = jumpIDs[routeAlias] ?? UUID() - jumpIDs[routeAlias] = id - items.append( - SSHImportItem( - id: id, category: .hosts, name: routeAlias, - source: items[index].source, warnings: base.warnings, host: host)) - } - previous = routeAlias - } - items[index].host?.jumps = [previous] - } - } - - func refreshDuplicates() { - for index in items.indices { - guard !items[index].unavailable else { continue } - if let key = items[index].key { - if let existing = library.keys.first(where: { - $0.fingerprint == key.fingerprint && $0.certificate == key.certificate - && library.keyAvailability(for: $0) == .available - }) { - items[index].existingID = existing.id - items[index].selected = false - } else if let duplicate = items[.. Bool = { - $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) - && $0.identity == entry.identity - } - if let existing = library.knownHosts.first(where: same) { - items[index].existingID = existing.id - items[index].selected = false - } else if let previous = items[.. Bool { !item.unavailable && item.existingID == nil } - func selectedItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] { - items.filter { (category == nil || $0.category == category) && selectable($0) && $0.selected } - } - func availableItems(_ category: SSHImportCategory? = nil) -> [SSHImportItem] { - items.filter { (category == nil || $0.category == category) && selectable($0) } - } - func setSelected(_ selected: Bool, category: SSHImportCategory? = nil) { - for index in items.indices - where selectable(items[index]) && (category == nil || items[index].category == category) { - items[index].selected = selected - } - } - - func keyOptions(for item: SSHImportItem) -> [SSHKeyRef] { - guard let host = item.host else { return [] } - if let id = host.existingKeyID, let key = library.key(id) { return [key] } - var seen: Set = [] - return host.identityPaths.flatMap { path in - items.filter { - guard $0.source == path && $0.category == .keys else { return false } - if host.certificatePaths.isEmpty { - return $0.certificatePaths.isEmpty || $0.certificatePaths.contains(path + "-cert.pub") - } - return !$0.certificatePaths.isEmpty && !Set($0.certificatePaths).isDisjoint(with: host.certificatePaths) - }.compactMap { candidate -> SSHKeyRef? in - if let id = candidate.existingID { - if let key = library.key(id) { return key } - if let other = items.first(where: { $0.id == id && $0.selected }) { return other.key } - return nil - } - return candidate.selected ? candidate.key : nil - } - }.filter { seen.insert($0.id).inserted } - } - - func chosenKey(for item: SSHImportItem) -> SSHKeyRef? { - let options = keyOptions(for: item) - return options.first { $0.id == item.preferredKeyID } ?? options.first - } - - private func identicalHost(_ item: SSHImportItem) -> Host? { - func matches(_ row: SSHImportItem, _ saved: Host, visited: Set) -> Bool { - guard !visited.contains(saved.id), visited.count < 8, let candidate = row.host, - saved.name == row.name && saved.host == candidate.hostname && saved.port == candidate.port, - let identity = saved.identityID.flatMap(library.identity), identity.username == candidate.username, - identity.keyID == chosenKey(for: row)?.id - else { return false } - if candidate.jumps.isEmpty { return saved.jumpHostID == nil } - guard candidate.jumps.count == 1, let alias = candidate.jumps.first, - let jump = saved.jumpHostID.flatMap(library.host) - else { return false } - if let next = items.first(where: { $0.host?.alias == alias }) { - return matches(next, jump, visited: visited.union([saved.id])) - } - return jump.name == alias - } - return library.hosts.first { matches(item, $0, visited: []) } - } - - func warnings(for item: SSHImportItem) -> [String] { - var warnings = item.warnings - if let entry = item.knownHost, - library.knownHosts.contains(where: { - let sameAddress = - $0.hostname == entry.hostname && ($0.isHashed || $0.port == entry.port) - || (!entry.isHashed && $0.matches(hostname: entry.hostname, port: entry.port)) - || (!$0.isHashed && entry.matches(hostname: $0.hostname, port: $0.port)) - return sameAddress && $0.identity != entry.identity - }) - { - warnings.append( - String(localized: "This address already has a different trusted fingerprint. Confirm adding this key.")) - } - return warnings - } - - func hasNameConflict(_ item: SSHImportItem) -> Bool { - guard item.host != nil else { return false } - return (library.hosts.contains { $0.name == item.name } && identicalHost(item) == nil) - || selectedItems(.hosts).contains { $0.id != item.id && $0.name == item.name } - } - - func problem(for item: SSHImportItem, includeWarningReminder: Bool = true) -> String? { - if !item.issue.isEmpty { return item.issue } - if item.requiresPassphrase { - return String(localized: "Enter the key passphrase to validate this private key.") - } - if includeWarningReminder && !warnings(for: item).isEmpty && !item.acceptedWarnings { - return String(localized: "Review and accept the import warnings.") - } - if let host = item.host { - guard !item.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - !host.hostname.isEmpty, (1...65_535).contains(host.port) - else { return String(localized: "Invalid SSH record.") } - if hasNameConflict(item) { - return String(localized: "A different host already uses this name. Rename this import or deselect it.") - } - for jump in host.jumps { - if !library.hosts.contains(where: { $0.name == jump }) - && !items.contains(where: { $0.host?.alias == jump && ($0.selected || $0.existingID != nil) }) - { - return String(localized: "Select the required jump host or deselect this host.") - } - } - } - return nil - } - - var canImport: Bool { - !isWorking && !completed && !selectedItems().isEmpty - && selectedItems().allSatisfy { problem(for: $0) == nil } - && (try? makeBatch()) != nil - } - - func unlock(_ id: UUID, passphrase: String) async { - guard let index = items.firstIndex(where: { $0.id == id }) else { return } - isWorking = true - defer { isWorking = false } - do { - items[index].key = try await reader.unlock(id: id, name: items[index].name, passphrase: passphrase) - items[index].requiresPassphrase = false - items[index].issue = "" - refreshDuplicates() - } catch { - items[index].issue = String(localized: "The key or passphrase is invalid. Retry or deselect this key.") - } - } - - func commit() async { - refreshDuplicates() - guard canImport else { return } - isWorking = true - error = nil - defer { isWorking = false } - do { - var batch = try makeBatch() - batch.credentials = try await reader.credentials(for: Set(batch.keys.map(\.id))) - // Revalidate after awaiting the reader, because another window or sync can write meanwhile. - refreshDuplicates() - let refreshed = try makeBatch() - guard Set(refreshed.keys.map(\.id)) == Set(batch.keys.map(\.id)), - selectedItems().allSatisfy({ problem(for: $0) == nil }) - else { - throw CocoaError(.validationMultipleErrors) - } - batch.hosts = refreshed.hosts - batch.identities = refreshed.identities - batch.knownHosts = refreshed.knownHosts - try library.commitSSHImport(batch) - importedCount = batch.keys.count + batch.hosts.count + batch.knownHosts.count - completed = true - await reader.discard() - } catch { self.error = error.localizedDescription } - } - - func discard() async { await reader.discard() } - - private func makeBatch() throws -> SSHImportBatch { - var batch = SSHImportBatch() - batch.keys = selectedItems(.keys).compactMap { item in - guard var key = item.key else { return nil } - key.storage = storage - return key - } - batch.knownHosts = selectedItems(.knownHosts).compactMap(\.knownHost) - var built: [UUID: Host] = [:] - var visiting: Set = [] - func build(_ item: SSHImportItem, forcedJump: UUID? = nil) throws -> Host { - if let existing = item.existingID.flatMap(library.host) { return existing } - if let existing = built[item.id] { - if let forcedJump, existing.jumpHostID != forcedJump { throw ImportRouteError.conflictingChain } - return existing - } - guard visiting.insert(item.id).inserted, visiting.count <= 8, let imported = item.host else { - throw ImportRouteError.invalid - } - defer { visiting.remove(item.id) } - var jumpID = forcedJump - for alias in imported.jumps { - if let candidate = items.first(where: { - $0.host?.alias == alias && ($0.selected || $0.existingID != nil) - }) { - jumpID = try build(candidate, forcedJump: jumpID).id - } else if let existing = library.hosts.first(where: { $0.name == alias }) { - if let jumpID, existing.jumpHostID != jumpID { throw ImportRouteError.conflictingChain } - jumpID = existing.id - } else { - throw ImportRouteError.invalid - } - } - let keyID = chosenKey(for: item)?.id - let identity: Identity - if let existing = (library.identities + batch.identities).first(where: { - $0.username == imported.username && $0.keyID == keyID - }) { - identity = existing - } else { - identity = Identity(name: imported.username, username: imported.username, keyID: keyID) - batch.identities.append(identity) - } - let host = Host( - id: item.id, name: item.name, host: imported.hostname, port: imported.port, - identityID: identity.id, jumpHostID: jumpID) - built[item.id] = host - return host - } - // Build routes with the most hops first so intermediate aliases get their chain before reuse. - for item in selectedItems(.hosts).sorted(by: { ($0.host?.jumps.count ?? 0) > ($1.host?.jumps.count ?? 0) }) { - _ = try build(item) - } - batch.hosts = built.values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } - let all = Dictionary(uniqueKeysWithValues: (library.hosts + batch.hosts).map { ($0.id, $0) }) - for host in batch.hosts { - var current: Host? = host - var seen: Set = [] - while let node = current { - guard seen.insert(node.id).inserted, seen.count <= 8 else { throw ImportRouteError.invalid } - current = node.jumpHostID.flatMap { all[$0] } - } - } - return batch - } - - var routeProblem: String? { - do { - _ = try makeBatch() - return nil - } catch { return error.localizedDescription } - } -} - -nonisolated private enum ImportRouteError: LocalizedError { - case invalid, conflictingChain - var errorDescription: String? { - switch self { - case .invalid: String(localized: "The imported jump route is missing, cyclic, or exceeds 8 hops.") - case .conflictingChain: - String(localized: "These hosts require conflicting jump routes. Import the routes separately.") - } +extension SSHImportModel { + convenience init(library: HostLibraryModel, reader: any SSHImportReading) { + self.init(library: library, reader: reader, defaultUsername: NSUserName()) } } diff --git a/TFM/Application/FeatureModels/SnippetLibraryModel.swift b/TFM/Application/FeatureModels/SnippetLibraryModel.swift index cd4b495c..091a9dd7 100644 --- a/TFM/Application/FeatureModels/SnippetLibraryModel.swift +++ b/TFM/Application/FeatureModels/SnippetLibraryModel.swift @@ -1,56 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// The Snippets feature's deliberately narrow view of the shared vault. -@Observable -final class SnippetLibraryModel { - private let library: HostLibraryModel - - init(library: HostLibraryModel) { - self.library = library - } - - var hosts: [Host] { library.hosts } - var snippets: [Snippet] { library.snippets } - var snippetPackages: [SnippetPackage] { library.snippetPackages } - var rootSnippetPackages: [SnippetPackage] { library.rootSnippetPackages } - var isSnippetsUngroupedExpanded: Bool { library.isSnippetsUngroupedExpanded } - - func snippetPackage(_ id: SnippetPackage.ID) -> SnippetPackage? { - library.snippetPackage(id) - } - func childSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { - library.childSnippetPackages(of: parent) - } - func descendantSnippetPackages(of parent: SnippetPackage.ID) -> [SnippetPackage] { - library.descendantSnippetPackages(of: parent) - } - func snippets(in package: SnippetPackage.ID?) -> [Snippet] { library.snippets(in: package) } - func snippetPackagePath(_ packageID: SnippetPackage.ID?) -> String? { - library.snippetPackagePath(packageID) - } - func searchSnippets(_ query: String) -> [Snippet] { library.searchSnippets(query) } - func searchHosts(_ query: String) -> [Host] { library.searchHosts(query) } - func loginAddress(for host: Host) -> String { library.loginAddress(for: host) } - - func setSnippetPackageExpanded(_ expanded: Bool, for packageID: SnippetPackage.ID) { - library.setSnippetPackageExpanded(expanded, for: packageID) - } - func setSnippetsUngroupedExpanded(_ expanded: Bool) { - library.setSnippetsUngroupedExpanded(expanded) - } - func upsert(_ snippet: Snippet) { library.upsert(snippet) } - func upsert(_ package: SnippetPackage) { library.upsert(package) } - func delete(snippetIDs: [Snippet.ID]) { library.delete(snippetIDs: snippetIDs) } - func delete(snippetPackageID: SnippetPackage.ID, keepSnippets: Bool) { - library.delete(snippetPackageID: snippetPackageID, keepSnippets: keepSnippets) - } - func move( - snippetIDs: [Snippet.ID], into packageID: SnippetPackage.ID?, - before next: Snippet.ID? = nil - ) { - library.move(snippetIDs: snippetIDs, into: packageID, before: next) - } -} +typealias SnippetLibraryModel = TermindApplication.SnippetLibraryModel diff --git a/TFM/Application/Metrics/MetricsServices.swift b/TFM/Application/Metrics/MetricsServices.swift index 3ea888dd..4b350ad6 100644 --- a/TFM/Application/Metrics/MetricsServices.swift +++ b/TFM/Application/Metrics/MetricsServices.swift @@ -1,21 +1,7 @@ import Foundation +import TermindApplication import TermindDomain -nonisolated protocol MetricsSampling: Sendable { - func connect(_ configuration: SSHConnectionConfiguration) async throws - func sample() async throws -> MetricsSample - func filesystems() async throws -> [MetricsFilesystem]? - func processes() async throws -> MetricsProcessSample? - func gpu() async throws -> NvidiaSnapshot? - func disconnect() async -} - -@MainActor -protocol MetricsConfigurationStoring { - func load() -> [MetricsSelection] - func save(_ selections: [MetricsSelection]) -} - nonisolated struct MetricsClock: Sendable { var now: @Sendable () -> Date = { Date() } var sleep: @Sendable (TimeInterval) async throws -> Void = { @@ -23,7 +9,5 @@ nonisolated struct MetricsClock: Sendable { } } -nonisolated extension MetricsSampling { - func filesystems() async throws -> [MetricsFilesystem]? { nil } - func processes() async throws -> MetricsProcessSample? { nil } -} +typealias MetricsSampling = TermindApplication.MetricsSampling +typealias MetricsConfigurationStoring = TermindApplication.MetricsConfigurationStoring diff --git a/TFM/Application/Repositories/AIConfigurationPersistence.swift b/TFM/Application/Repositories/AIConfigurationPersistence.swift index 7916870d..e1c2eacd 100644 --- a/TFM/Application/Repositories/AIConfigurationPersistence.swift +++ b/TFM/Application/Repositories/AIConfigurationPersistence.swift @@ -1,24 +1,5 @@ -import AgentKit -import Foundation -import TermindDomain +import TermindApplication -nonisolated struct AIConfigurationSnapshot: Sendable { - var providers: [ModelProvider] - /// What was typed and picked per provider — see `AIConfigurationModel.endpoints`. - var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] - var roles: [AIRole: AIModelRef] - var lastSelectedModel: AIModelRef? - var mcpServers: [MCPServer] = [] -} - -@MainActor -protocol AIConfigurationPersisting: AnyObject { - func load() throws -> AIConfigurationSnapshot - func save(_ snapshot: AIConfigurationSnapshot) throws -} - -@MainActor -protocol AIWebSearchPersisting: AnyObject { - func loadWebSearch() throws -> AIWebSearchPreferences - func saveWebSearch(_ value: AIWebSearchPreferences) throws -} +typealias AIConfigurationSnapshot = TermindApplication.AIConfigurationSnapshot +typealias AIConfigurationPersisting = TermindApplication.AIConfigurationPersisting +typealias AIWebSearchPersisting = TermindApplication.AIWebSearchPersisting diff --git a/TFM/Application/Repositories/SSHImportBatch.swift b/TFM/Application/Repositories/SSHImportBatch.swift index e3e2dd98..481365eb 100644 --- a/TFM/Application/Repositories/SSHImportBatch.swift +++ b/TFM/Application/Repositories/SSHImportBatch.swift @@ -1,13 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -import struct TermindDomain.Host - -/// Execution-only payload. Secrets are never part of the observable import preview. -nonisolated struct SSHImportBatch: Sendable { - var hosts: [Host] = [] - var identities: [Identity] = [] - var keys: [SSHKeyRef] = [] - var knownHosts: [KnownHostEntry] = [] - var credentials: [UUID: StoredSSHKeyCredential] = [:] -} +typealias SSHImportBatch = TermindApplication.SSHImportBatch diff --git a/TFM/Application/Repositories/SSHImportReading.swift b/TFM/Application/Repositories/SSHImportReading.swift index 09724bab..b38c18c9 100644 --- a/TFM/Application/Repositories/SSHImportReading.swift +++ b/TFM/Application/Repositories/SSHImportReading.swift @@ -1,9 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -nonisolated protocol SSHImportReading: Sendable { - func scan(root: URL, knownHostsOnly: Bool, grants: [URL]) async throws -> [SSHImportItem] - func unlock(id: UUID, name: String, passphrase: String) async throws -> SSHKeyRef - func credentials(for ids: Set) async throws -> [UUID: StoredSSHKeyCredential] - func discard() async -} +typealias SSHImportReading = TermindApplication.SSHImportReading diff --git a/TFM/Application/Repositories/VaultPersistence.swift b/TFM/Application/Repositories/VaultPersistence.swift index 37a347a1..34ed32db 100644 --- a/TFM/Application/Repositories/VaultPersistence.swift +++ b/TFM/Application/Repositories/VaultPersistence.swift @@ -1,28 +1,6 @@ -import Foundation +import TermindApplication import TermindDomain -import struct TermindDomain.Host - -nonisolated struct VaultSnapshot: Sendable { - var hosts: [Host] = [] - var groups: [HostGroup] = [] - var identities: [Identity] = [] - var keys: [SSHKeyRef] = [] - var knownHosts: [KnownHostEntry] = [] - var snippets: [Snippet] = [] - var snippetPackages: [SnippetPackage] = [] - var portForwardings: [PortForwardingRule] = [] -} - -nonisolated struct VaultRemoteApplyResult: Sendable { - var snapshot: VaultSnapshot - var acceptedKeys: Set - var needsUpload: Set -} - -@MainActor -protocol VaultPersisting: AnyObject { - func load() throws -> VaultSnapshot - func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet - func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult -} +typealias VaultSnapshot = TermindDomain.VaultSnapshot +typealias VaultRemoteApplyResult = TermindDomain.VaultRemoteApplyResult +typealias VaultPersisting = TermindApplication.VaultPersisting diff --git a/TFM/Application/Repositories/VaultStore.swift b/TFM/Application/Repositories/VaultStore.swift index ec23bafb..613d0b8b 100644 --- a/TFM/Application/Repositories/VaultStore.swift +++ b/TFM/Application/Repositories/VaultStore.swift @@ -1,34 +1,3 @@ -import Foundation -import TermindDomain +import TermindApplication -/// Persistence boundary shared by the Vault feature models. It keeps concrete -/// storage and sync notifications out of aggregate models while preserving the -/// existing `VaultSnapshot` transaction and on-disk schema. -@MainActor -final class VaultStore { - private let persistence: (any VaultPersisting)? - var didPersist: (() -> Void)? - var didRecordChanges: ((VaultMutationSet) -> Void)? - - init(persistence: (any VaultPersisting)? = nil) { - self.persistence = persistence - } - - func load(fallback: VaultSnapshot) throws -> VaultSnapshot { - try persistence?.load() ?? fallback - } - - func save(_ snapshot: VaultSnapshot) throws { - if let persistence { - let mutations = try persistence.save(snapshot) - didRecordChanges?(mutations) - } - didPersist?() - } - - /// A remote projection is already persisted by the sync engine. Aggregate - /// models only need to refresh repository projections after accepting it. - func didApplyRemoteSnapshot() { - didPersist?() - } -} +typealias VaultStore = TermindApplication.VaultStore diff --git a/TFM/Application/Security/AISecretStoring.swift b/TFM/Application/Security/AISecretStoring.swift index d231c989..706be2d7 100644 --- a/TFM/Application/Security/AISecretStoring.swift +++ b/TFM/Application/Security/AISecretStoring.swift @@ -1,7 +1,3 @@ -import Foundation +import TermindApplication -@MainActor -protocol AISecretStoring: AnyObject { - func string(for reference: String) throws -> String? - func setString(_ value: String?, for reference: String) throws -} +typealias AISecretStoring = TermindApplication.AISecretStoring diff --git a/TFM/Application/Security/SecretStoring.swift b/TFM/Application/Security/SecretStoring.swift index c2392b59..b7fd8446 100644 --- a/TFM/Application/Security/SecretStoring.swift +++ b/TFM/Application/Security/SecretStoring.swift @@ -1,53 +1,5 @@ -import Foundation -import TermindDomain +import TermindApplication -nonisolated struct SSHPrivateKeySecret: Codable, Hashable, Sendable { - var privateKey: String -} - -nonisolated protocol SecretStoring: Sendable { - func string(for account: String, storage: PasswordStorage) throws -> String? - func setString(_ value: String?, for account: String, storage: PasswordStorage) throws - func keyCredential( - for account: String, storage: KeyStorage - ) throws -> StoredSSHKeyCredential? - func setKeyCredential( - _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage - ) throws - func removePassword(_ account: String, storage: PasswordStorage) throws - func removeKeyCredential(_ account: String, storage: KeyStorage) throws -} - -nonisolated final class InMemorySecretStore: SecretStoring, @unchecked Sendable { - private let lock = NSLock() - private var strings: [String: String] = [:] - private var keys: [String: StoredSSHKeyCredential] = [:] - - func string(for account: String, storage: PasswordStorage) throws -> String? { - lock.withLock { strings[account] } - } - - func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { - lock.withLock { strings[account] = value } - } - - func keyCredential( - for account: String, storage: KeyStorage - ) throws -> StoredSSHKeyCredential? { - lock.withLock { keys[account] } - } - - func setKeyCredential( - _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage - ) throws { - lock.withLock { keys[account] = value } - } - - func removePassword(_ account: String, storage: PasswordStorage) throws { - lock.withLock { strings[account] = nil } - } - - func removeKeyCredential(_ account: String, storage: KeyStorage) throws { - lock.withLock { keys[account] = nil } - } -} +typealias SSHPrivateKeySecret = TermindApplication.SSHPrivateKeySecret +typealias SecretStoring = TermindApplication.SecretStoring +typealias InMemorySecretStore = TermindApplication.InMemorySecretStore diff --git a/TFM/Application/Security/StoredSSHKeyCredential.swift b/TFM/Application/Security/StoredSSHKeyCredential.swift index 832814d6..c8108583 100644 --- a/TFM/Application/Security/StoredSSHKeyCredential.swift +++ b/TFM/Application/Security/StoredSSHKeyCredential.swift @@ -1,16 +1,5 @@ -import Foundation +import TermindApplication -nonisolated struct SecureEnclaveKeyCredential: Codable, Hashable, Sendable { - var dataRepresentation: Data -} - -nonisolated enum StoredSSHKeyCredential: Hashable, Sendable { - case software(SSHPrivateKeySecret) - case secureEnclave(SecureEnclaveKeyCredential) -} - -nonisolated struct SecureEnclaveKeyMaterial: Sendable { - var credential: SecureEnclaveKeyCredential - var publicKey: String - var fingerprint: String -} +typealias SecureEnclaveKeyCredential = TermindApplication.SecureEnclaveKeyCredential +typealias StoredSSHKeyCredential = TermindApplication.StoredSSHKeyCredential +typealias SecureEnclaveKeyMaterial = TermindApplication.SecureEnclaveKeyMaterial diff --git a/TFM/Application/Transfers/RemotePath.swift b/TFM/Application/Transfers/RemotePath.swift new file mode 100644 index 00000000..99f0bfbe --- /dev/null +++ b/TFM/Application/Transfers/RemotePath.swift @@ -0,0 +1,4 @@ +import TermindDomain + +typealias RemotePath = TermindDomain.RemotePath +typealias RemoteHomeResolutionState = TermindDomain.RemoteHomeResolutionState diff --git a/TFM/Application/Transfers/SFTPTransferCoordinator.swift b/TFM/Application/Transfers/SFTPTransferCoordinator.swift index 64e903a7..4ef8d5a0 100644 --- a/TFM/Application/Transfers/SFTPTransferCoordinator.swift +++ b/TFM/Application/Transfers/SFTPTransferCoordinator.swift @@ -1,663 +1,9 @@ -import Foundation -import TermindDomain +import TermindRemoteInfrastructure -import struct TermindDomain.Host - -nonisolated struct SFTPRemoteItemsPayload: Codable, Hashable, Sendable { - var token: UUID -} - -nonisolated enum SFTPTransferConnection: Sendable { - case saved(SSHConnectionConfiguration) - case borrowed(id: UUID, client: any SFTPClientProtocol) - - var borrowedID: UUID? { - if case .borrowed(let id, _) = self { id } else { nil } - } -} - -nonisolated struct SFTPRemoteCopySource: Sendable { - var hostID: Host.ID - var connection: SFTPTransferConnection - var files: [RemoteFile] - var homeDirectory: String? - - init( - hostID: Host.ID, connection: SFTPTransferConnection, files: [RemoteFile], - homeDirectory: String? = nil - ) { - self.hostID = hostID - self.connection = connection - self.files = files - self.homeDirectory = homeDirectory - } - - init(hostID: Host.ID, configuration: SSHConnectionConfiguration, files: [RemoteFile]) { - self.init(hostID: hostID, connection: .saved(configuration), files: files) - } -} - -nonisolated struct SFTPRemoteManifestEntry: Sendable { - var sourcePath: String - var relativePath: String - var kind: RemoteFile.Kind - var byteCount: Int64 - - var isDirectory: Bool { kind == .directory } -} - -nonisolated enum TransferConflictPolicy: String, CaseIterable, Identifiable, Sendable { - case replace, skip, keepBoth - var id: String { rawValue } -} - -nonisolated struct SFTPTransferSummary: Equatable, Sendable { - var activeCount: Int - var failedCount: Int - var aggregateProgress: Double? -} - -nonisolated enum RemotePath { - struct Component: Identifiable, Equatable, Sendable { - var name: String - var path: String - var id: String { path } - } - - static func normalized(_ path: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return "/" } - var components: [Substring] = [] - for component in trimmed.split(separator: "/") { - switch component { - case ".": continue - case "..": if !components.isEmpty { components.removeLast() } - default: components.append(component) - } - } - return "/" + components.joined(separator: "/") - } - - static func join(_ base: String, _ name: String) -> String { - let base = normalized(base) - return base == "/" ? "/\(name)" : "\(base)/\(name)" - } - - static func parent(of path: String) -> String { - let value = normalized(path) - guard value != "/" else { return "/" } - let parent = (value as NSString).deletingLastPathComponent - return parent.isEmpty ? "/" : parent - } - - static func components(of path: String) -> [Component] { - let value = normalized(path) - var result = [Component(name: "/", path: "/")] - guard value != "/" else { return result } - var current = "" - for name in value.split(separator: "/").map(String.init) { - current += "/" + name - result.append(Component(name: name, path: current)) - } - return result - } - - static func displayed(_ path: String, home: RemoteHomeResolutionState) -> String { - let value = normalized(path) - guard case .resolved(let proposedHome) = home else { return value } - let home = normalized(proposedHome) - if value == home { return "~" } - if home == "/" { return "~" + value } - guard value.hasPrefix(home + "/") else { return value } - return "~" + value.dropFirst(home.count) - } - - static func displayedComponents( - of path: String, home: RemoteHomeResolutionState - ) -> [Component] { - let value = normalized(path) - guard case .resolved(let proposedHome) = home else { return components(of: value) } - let home = normalized(proposedHome) - guard value == home || home == "/" || value.hasPrefix(home + "/") else { - return components(of: value) - } - var result = [Component(name: "~", path: home)] - guard value != home else { return result } - var current = home - let suffix = home == "/" ? value.dropFirst() : value.dropFirst(home.count + 1) - for name in suffix.split(separator: "/").map(String.init) { - current = join(current, name) - result.append(Component(name: name, path: current)) - } - return result - } - - static func keepBothCandidate(for path: String, index: Int) -> String { - let ns = path as NSString - let parent = ns.deletingLastPathComponent - let name = ns.lastPathComponent as NSString - let ext = name.pathExtension - let stem = name.deletingPathExtension - let candidate = ext.isEmpty ? "\(stem).\(index)" : "\(stem).\(index).\(ext)" - return parent == "/" ? "/\(candidate)" : "\(parent)/\(candidate)" - } -} - -nonisolated enum RemoteHomeResolutionState: Hashable, Sendable { - case pending - case resolved(String) - case unavailable - - var resolvedDirectory: String? { - guard case .resolved(let path) = self else { return nil } - return RemotePath.normalized(path) - } - - var isPending: Bool { - if case .pending = self { true } else { false } - } -} - -@Observable -final class SFTPTransferCoordinator { - typealias ClientFactory = @Sendable () -> any SFTPClientProtocol - - private struct Runtime { - var connection: SFTPTransferConnection - var localURL: URL - var remotePath: String - var overwrite: Bool - var scopedRoot: URL - var cancellation: TransferCancellationToken - var completion: ((SFTPTransfer.State) -> Void)? - } - - private struct RemoteCopyRuntime { - var sourceHostID: Host.ID - var sourceConnection: SFTPTransferConnection - var sourcePath: String - var destinationHostID: Host.ID - var destinationConnection: SFTPTransferConnection - var destinationPath: String - var overwrite: Bool - var cancellation: TransferCancellationToken - var completion: ((SFTPTransfer.State) -> Void)? - } - - private struct ClientLease { - let client: any SFTPClientProtocol - let isOwned: Bool - - func disconnect() async { - if isOwned { await client.disconnect() } - } - } - - private let store: HostLibraryModel - private let clientFactory: ClientFactory - private var runtimes: [SFTPTransfer.ID: Runtime] = [:] - private var remoteCopyRuntimes: [SFTPTransfer.ID: RemoteCopyRuntime] = [:] - private var activeTasks: [SFTPTransfer.ID: Task] = [:] - private var activePerHost: [Host.ID: Int] = [:] - private var activeBorrowedConnections: Set = [] - private var remoteCopySources: [UUID: (source: SFTPRemoteCopySource, createdAt: Date)] = [:] - - private(set) var globalLimit: Int - private(set) var perHostLimit: Int - - init( - store: HostLibraryModel, - clientFactory: @escaping ClientFactory, - globalLimit: Int? = nil, perHostLimit: Int? = nil - ) { - self.store = store - self.clientFactory = clientFactory - let defaults = UserDefaults.standard - self.globalLimit = Self.normalizedLimit( - globalLimit ?? defaults.object(forKey: SettingsKey.sftpGlobalTransferLimit) as? Int - ?? SettingsKey.defaultSFTPGlobalTransferLimit - ) - self.perHostLimit = Self.normalizedLimit( - perHostLimit ?? defaults.object(forKey: SettingsKey.sftpPerHostTransferLimit) as? Int - ?? SettingsKey.defaultSFTPPerHostTransferLimit - ) - } - - func updateLimits(global: Int, perHost: Int) { - globalLimit = Self.normalizedLimit(global) - perHostLimit = min(Self.normalizedLimit(perHost), globalLimit) - schedule() - } - - private static func normalizedLimit(_ value: Int) -> Int { min(max(value, 1), 8) } - - var sftpTransfers: [SFTPTransfer] { - store.sftpTransfers.sorted { $0.queuedAt < $1.queuedAt } - } - var activeCount: Int { activeTasks.count } - var summary: SFTPTransferSummary { - let active = sftpTransfers.filter { $0.state == .queued || $0.state == .running } - let failedCount = sftpTransfers.filter { - if case .failed = $0.state { true } else { false } - }.count - let measurable = active.filter { $0.byteCount > 0 } - let total = measurable.reduce(Int64(0)) { $0 + $1.byteCount } - let completed = measurable.reduce(Int64(0)) { - $0 + min($1.transferredBytes, $1.byteCount) - } - let aggregateProgress: Double? - if total > 0 { - aggregateProgress = min(1, Double(completed) / Double(total)) - } else { - aggregateProgress = nil - } - return SFTPTransferSummary( - activeCount: active.count, - failedCount: failedCount, - aggregateProgress: aggregateProgress - ) - } - - func registerRemoteCopySource(_ source: SFTPRemoteCopySource) -> SFTPRemoteItemsPayload { - let expiration = Date().addingTimeInterval(-60 * 60) - remoteCopySources = remoteCopySources.filter { $0.value.createdAt > expiration } - let payload = SFTPRemoteItemsPayload(token: UUID()) - remoteCopySources[payload.token] = (source, Date()) - return payload - } - - func remoteCopySource(for payload: SFTPRemoteItemsPayload) -> SFTPRemoteCopySource? { - remoteCopySources[payload.token]?.source - } - - func remoteManifest(for payload: SFTPRemoteItemsPayload) async throws - -> (source: SFTPRemoteCopySource, entries: [SFTPRemoteManifestEntry]) - { - guard let source = remoteCopySource(for: payload) else { - throw SFTPError.operation("The copied SFTP items are no longer available.") - } - let borrowedID = source.connection.borrowedID - if let borrowedID { - try await reserveBorrowedConnection(borrowedID) - } - defer { - if let borrowedID { releaseBorrowedConnections([borrowedID]) } - } - let lease = try await clientLease(for: source.connection) - let client = lease.client - do { - var entries: [SFTPRemoteManifestEntry] = [] - func collect(_ file: RemoteFile, relativePath: String) async throws { - entries.append( - SFTPRemoteManifestEntry( - sourcePath: file.path, relativePath: relativePath, - kind: file.kind, byteCount: file.size - )) - guard file.isDirectory else { return } - for child in try await client.listDirectory(at: file.path) { - try await collect( - child, - relativePath: relativePath + "/" + child.name - ) - } - } - for file in source.files { - try await collect(file, relativePath: file.name) - } - await lease.disconnect() - return (source, entries) - } catch { - await lease.disconnect() - throw error - } - } - - func enqueue( - direction: SFTPTransfer.Direction, batchID: UUID, - localURL: URL, remotePath: String, byteCount: Int64, - hostID: Host.ID, connection: SFTPTransferConnection, - scopedRoot: URL, overwrite: Bool, - homeDirectory: String? = nil, - completion: ((SFTPTransfer.State) -> Void)? = nil - ) { - let sftpTransfer = SFTPTransfer( - batchID: batchID, hostID: hostID, - sourceHostID: direction == .download ? hostID : nil, - destinationHostID: direction == .upload ? hostID : nil, - direction: direction, - localPath: localURL.path, remotePath: remotePath, - sourceHomeDirectory: direction == .download ? homeDirectory : nil, - destinationHomeDirectory: direction == .upload ? homeDirectory : nil, - byteCount: byteCount, queuedAt: Date() - ) - store.enqueueSFTPTransfer(sftpTransfer) - runtimes[sftpTransfer.id] = Runtime( - connection: connection, localURL: localURL, - remotePath: remotePath, overwrite: overwrite, - scopedRoot: scopedRoot, cancellation: TransferCancellationToken(), - completion: completion - ) - schedule() - } - - func enqueue( - direction: SFTPTransfer.Direction, batchID: UUID, - localURL: URL, remotePath: String, byteCount: Int64, - hostID: Host.ID, configuration: SSHConnectionConfiguration, - scopedRoot: URL, overwrite: Bool, - homeDirectory: String? = nil, - completion: ((SFTPTransfer.State) -> Void)? = nil - ) { - enqueue( - direction: direction, batchID: batchID, localURL: localURL, - remotePath: remotePath, byteCount: byteCount, hostID: hostID, - connection: .saved(configuration), scopedRoot: scopedRoot, - overwrite: overwrite, homeDirectory: homeDirectory, completion: completion - ) - } - - func enqueueRemoteCopy( - batchID: UUID, - sourcePath: String, sourceHostID: Host.ID, - sourceConnection: SFTPTransferConnection, - destinationPath: String, destinationHostID: Host.ID, - destinationConnection: SFTPTransferConnection, - byteCount: Int64, overwrite: Bool, - sourceHomeDirectory: String? = nil, - destinationHomeDirectory: String? = nil, - completion: ((SFTPTransfer.State) -> Void)? = nil - ) { - let sftpTransfer = SFTPTransfer( - batchID: batchID, hostID: destinationHostID, - sourceHostID: sourceHostID, destinationHostID: destinationHostID, - direction: .copy, - localPath: sourcePath, remotePath: destinationPath, - sourceHomeDirectory: sourceHomeDirectory, - destinationHomeDirectory: destinationHomeDirectory, - byteCount: byteCount, queuedAt: Date() - ) - store.enqueueSFTPTransfer(sftpTransfer) - remoteCopyRuntimes[sftpTransfer.id] = RemoteCopyRuntime( - sourceHostID: sourceHostID, - sourceConnection: sourceConnection, - sourcePath: sourcePath, - destinationHostID: destinationHostID, - destinationConnection: destinationConnection, - destinationPath: destinationPath, - overwrite: overwrite, - cancellation: TransferCancellationToken(), - completion: completion - ) - schedule() - } - - func enqueueRemoteCopy( - batchID: UUID, - sourcePath: String, sourceHostID: Host.ID, - sourceConfiguration: SSHConnectionConfiguration, - destinationPath: String, destinationHostID: Host.ID, - destinationConfiguration: SSHConnectionConfiguration, - byteCount: Int64, overwrite: Bool, - completion: ((SFTPTransfer.State) -> Void)? = nil - ) { - enqueueRemoteCopy( - batchID: batchID, sourcePath: sourcePath, sourceHostID: sourceHostID, - sourceConnection: .saved(sourceConfiguration), - destinationPath: destinationPath, destinationHostID: destinationHostID, - destinationConnection: .saved(destinationConfiguration), - byteCount: byteCount, overwrite: overwrite, completion: completion - ) - } - - func cancel(_ id: SFTPTransfer.ID) { - guard let sftpTransfer = store.sftpTransfer(id) else { return } - if sftpTransfer.state == .queued { - runtimes.removeValue(forKey: id) - remoteCopyRuntimes.removeValue(forKey: id) - store.finishSFTPTransfer(id, state: .cancelled) - } else { - if let runtime = runtimes[id] { - runtime.cancellation.cancel() - } else { - remoteCopyRuntimes[id]?.cancellation.cancel() - } - } - } - - func cancelAll() { - let ids = store.sftpTransfers.compactMap { sftpTransfer -> SFTPTransfer.ID? in - switch sftpTransfer.state { - case .queued, .running: sftpTransfer.id - case .paused, .completed, .failed, .cancelled: nil - } - } - for id in ids { cancel(id) } - } - - func cancelAll(for hostID: Host.ID) { - for transfer in store.sftpTransfers where transfer.involves(hostID) { - cancel(transfer.id) - } - } - - func cancelTransfers(usingBorrowedConnection connectionID: UUID) async { - let ids = store.sftpTransfers.compactMap { transfer -> SFTPTransfer.ID? in - guard transfer.state == .queued || transfer.state == .running, - borrowedIDs(for: transfer).contains(connectionID) - else { return nil } - return transfer.id - } - for id in ids { cancel(id) } - while activeBorrowedConnections.contains(connectionID) { - try? await Task.sleep(for: .milliseconds(10)) - } - } - - func clearFinished() { store.clearFinishedSFTPTransfers() } - - private func schedule() { - while activeTasks.count < globalLimit { - guard - let sftpTransfer = store.sftpTransfers.first(where: { sftpTransfer in - guard sftpTransfer.state == .queued, - runtimes[sftpTransfer.id] != nil || remoteCopyRuntimes[sftpTransfer.id] != nil - else { return false } - let borrowedIDs = borrowedIDs(for: sftpTransfer) - guard borrowedIDs.allSatisfy({ !activeBorrowedConnections.contains($0) }) else { - return false - } - return hostIDs(for: sftpTransfer).allSatisfy { - activePerHost[$0, default: 0] < perHostLimit - } - }) - else { return } - start(sftpTransfer) - } - } - - private func start(_ sftpTransfer: SFTPTransfer) { - if let runtime = remoteCopyRuntimes[sftpTransfer.id] { - startRemoteCopy(sftpTransfer, runtime: runtime) - return - } - guard let hostID = sftpTransfer.hostID, let runtime = runtimes[sftpTransfer.id] else { return } - store.setSFTPTransferState(.running, for: sftpTransfer.id) - activePerHost[hostID, default: 0] += 1 - let borrowedIDs = Array(runtime.connection.borrowedID.map { [$0] } ?? []) - activeBorrowedConnections.formUnion(borrowedIDs) - let task = Task { [weak self] in - guard let self else { return } - let scoped = runtime.scopedRoot.startAccessingSecurityScopedResource() - defer { if scoped { runtime.scopedRoot.stopAccessingSecurityScopedResource() } } - var lease: ClientLease? - let result: SFTPTransfer.State - do { - let acquired = try await clientLease(for: runtime.connection) - lease = acquired - let client = acquired.client - let progress: @Sendable (SFTPProgress) -> Void = { [weak self] value in - Task { @MainActor in - self?.store.updateSFTPTransfer( - sftpTransfer.id, completedBytes: value.completedBytes, - totalBytes: value.totalBytes, bytesPerSecond: value.bytesPerSecond - ) - } - } - switch sftpTransfer.direction { - case .upload: - try await client.uploadFile( - from: runtime.localURL, to: runtime.remotePath, - overwrite: runtime.overwrite, cancellation: runtime.cancellation, - progress: progress - ) - case .download: - try FileManager.default.createDirectory( - at: runtime.localURL.deletingLastPathComponent(), - withIntermediateDirectories: true - ) - try await client.downloadFile( - from: runtime.remotePath, to: runtime.localURL, - overwrite: runtime.overwrite, cancellation: runtime.cancellation, - progress: progress - ) - case .copy: - throw SFTPError.operation("Invalid copy transfer runtime.") - } - result = .completed - } catch SFTPError.cancelled { - result = .cancelled - } catch is CancellationError { - result = .cancelled - } catch { - result = .failed(error.localizedDescription) - } - await lease?.disconnect() - finish( - sftpTransfer.id, hostIDs: [hostID], borrowedIDs: borrowedIDs, - state: result - ) - } - activeTasks[sftpTransfer.id] = task - } - - private func startRemoteCopy(_ sftpTransfer: SFTPTransfer, runtime: RemoteCopyRuntime) { - let involvedHostIDs = Array(Set([runtime.sourceHostID, runtime.destinationHostID])) - store.setSFTPTransferState(.running, for: sftpTransfer.id) - for hostID in involvedHostIDs { activePerHost[hostID, default: 0] += 1 } - let borrowedIDs = [ - runtime.sourceConnection.borrowedID, - runtime.destinationConnection.borrowedID, - ].compactMap { $0 } - activeBorrowedConnections.formUnion(borrowedIDs) - let task = Task { [weak self] in - guard let self else { return } - var sourceLease: ClientLease? - var destinationLease: ClientLease? - let result: SFTPTransfer.State - do { - let acquiredSource = try await clientLease(for: runtime.sourceConnection) - sourceLease = acquiredSource - let acquiredDestination = try await clientLease( - for: runtime.destinationConnection - ) - destinationLease = acquiredDestination - let progress: @Sendable (SFTPProgress) -> Void = { [weak self] value in - Task { @MainActor in - self?.store.updateSFTPTransfer( - sftpTransfer.id, completedBytes: value.completedBytes, - totalBytes: value.totalBytes, - bytesPerSecond: value.bytesPerSecond - ) - } - } - try await acquiredSource.client.copyFile( - from: runtime.sourcePath, - to: runtime.destinationPath, - on: acquiredDestination.client, - byteCount: sftpTransfer.byteCount, - overwrite: runtime.overwrite, - cancellation: runtime.cancellation, - progress: progress - ) - result = .completed - } catch SFTPError.cancelled { - result = .cancelled - } catch is CancellationError { - result = .cancelled - } catch { - result = .failed(error.localizedDescription) - } - await sourceLease?.disconnect() - await destinationLease?.disconnect() - finish( - sftpTransfer.id, hostIDs: involvedHostIDs, - borrowedIDs: borrowedIDs, state: result - ) - } - activeTasks[sftpTransfer.id] = task - } - - private func hostIDs(for sftpTransfer: SFTPTransfer) -> [Host.ID] { - if let runtime = remoteCopyRuntimes[sftpTransfer.id] { - return Array(Set([runtime.sourceHostID, runtime.destinationHostID])) - } - return sftpTransfer.hostID.map { [$0] } ?? [] - } - - private func clientLease( - for connection: SFTPTransferConnection - ) async throws -> ClientLease { - switch connection { - case .saved(let configuration): - let client = clientFactory() - do { - try await client.connect(using: configuration, progress: nil) - } catch { - await client.disconnect() - throw error - } - return ClientLease(client: client, isOwned: true) - case .borrowed(_, let client): - return ClientLease(client: client, isOwned: false) - } - } - - private func borrowedIDs(for sftpTransfer: SFTPTransfer) -> [UUID] { - if let runtime = remoteCopyRuntimes[sftpTransfer.id] { - return [ - runtime.sourceConnection.borrowedID, - runtime.destinationConnection.borrowedID, - ].compactMap { $0 } - } - return runtimes[sftpTransfer.id]?.connection.borrowedID.map { [$0] } ?? [] - } - - private func reserveBorrowedConnection(_ id: UUID) async throws { - while activeBorrowedConnections.contains(id) { - try Task.checkCancellation() - try await Task.sleep(for: .milliseconds(10)) - } - activeBorrowedConnections.insert(id) - } - - private func releaseBorrowedConnections(_ ids: [UUID]) { - activeBorrowedConnections.subtract(ids) - schedule() - } - - private func finish( - _ id: SFTPTransfer.ID, hostIDs: [Host.ID], borrowedIDs: [UUID] = [], - state: SFTPTransfer.State - ) { - activeTasks.removeValue(forKey: id) - for hostID in hostIDs { - activePerHost[hostID] = max(0, activePerHost[hostID, default: 1] - 1) - } - releaseBorrowedConnections(borrowedIDs) - let completion = - runtimes.removeValue(forKey: id)?.completion - ?? remoteCopyRuntimes.removeValue(forKey: id)?.completion - store.finishSFTPTransfer(id, state: state) - completion?(state) - } -} +typealias SFTPRemoteItemsPayload = TermindRemoteInfrastructure.SFTPRemoteItemsPayload +typealias SFTPTransferConnection = TermindRemoteInfrastructure.SFTPTransferConnection +typealias SFTPRemoteCopySource = TermindRemoteInfrastructure.SFTPRemoteCopySource +typealias SFTPRemoteManifestEntry = TermindRemoteInfrastructure.SFTPRemoteManifestEntry +typealias TransferConflictPolicy = TermindRemoteInfrastructure.TransferConflictPolicy +typealias SFTPTransferSummary = TermindRemoteInfrastructure.SFTPTransferSummary +typealias SFTPTransferCoordinator = TermindRemoteInfrastructure.SFTPTransferCoordinator diff --git a/TFM/DesignSystem/LinuxDistributionPresentation.swift b/TFM/DesignSystem/LinuxDistributionPresentation.swift index 7f8f98cf..3b5ec83a 100644 --- a/TFM/DesignSystem/LinuxDistributionPresentation.swift +++ b/TFM/DesignSystem/LinuxDistributionPresentation.swift @@ -2,40 +2,10 @@ import AppKit import Foundation import SwiftUI import TermindDomain +import TermindPresentation -nonisolated struct LinuxDistributionIconEntry: Codable, Hashable, Sendable { - var title: String - var asset: String - var ids: [String] - var hex: String -} - -nonisolated struct LinuxDistributionIconCatalog: Codable, Sendable { - var entries: [LinuxDistributionIconEntry] - - private static let bundled: Self? = { - guard - let url = Bundle.main.url( - forResource: "LinuxDistributionIcons", withExtension: "json" - ), let data = try? Data(contentsOf: url) - else { return nil } - return try? JSONDecoder().decode(Self.self, from: data) - }() - - static func entry(for distribution: DetectedDistribution?) -> LinuxDistributionIconEntry? { - guard let distribution, let catalog = bundled else { return nil } - return catalog.entry(matching: distribution) - } - - func entry(matching distribution: DetectedDistribution) -> LinuxDistributionIconEntry? { - let byID = Dictionary( - entries.flatMap { entry in entry.ids.map { ($0.lowercased(), entry) } }, - uniquingKeysWith: { first, _ in first } - ) - if let exact = byID[distribution.id] { return exact } - return distribution.likeIDs.lazy.compactMap { byID[$0] }.first - } -} +typealias LinuxDistributionIconEntry = TermindPresentation.LinuxDistributionIconEntry +typealias LinuxDistributionIconCatalog = TermindPresentation.LinuxDistributionIconCatalog /// Converts a Simple Icons brand hex into a foreground that remains legible on /// CardIcon's 16%-opacity tint surface. Only luminance moves, toward the current diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index 73a1d4f5..3d769b96 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindDomain +import TermindPresentation import struct TermindDomain.Host @@ -53,22 +54,7 @@ struct HostEditorView: View { private var form: some View { Form { - Section("Connection") { - TextField("Name", text: $draft.name, prompt: Text(draft.host)) - TextField("Host", text: $draft.host, prompt: Text("IP address or hostname")) - TextField("Port", value: $draft.port, format: .number.grouping(.never)) - } - - Section("SFTP") { - TextField( - "Initial Path", text: $draft.sftpSessionInitialPath, - prompt: Text("~") - ) - .font(.body.monospaced()) - Text("Leave blank for the remote home directory, or enter an absolute path.") - .font(.caption) - .foregroundStyle(.secondary) - } + HostConnectionFields(host: $draft) Section("Authentication") { // One control, because a host says *where* and an identity says diff --git a/TFM/Features/Ports/PortForwardingView.swift b/TFM/Features/Ports/PortForwardingView.swift index a67ec257..f30bf7ae 100644 --- a/TFM/Features/Ports/PortForwardingView.swift +++ b/TFM/Features/Ports/PortForwardingView.swift @@ -1,5 +1,6 @@ import SwiftUI import TermindDomain +import TermindPresentation import struct TermindDomain.Host @@ -232,17 +233,7 @@ struct PortForwardingEditorSheet: View { ) { Form { Section { - Picker("Direction", selection: $draft.kind) { - Text("Local").tag(PortForwardingRule.Kind.local) - .accessibilityLabel("Local Forward") - Text("Remote").tag(PortForwardingRule.Kind.remote) - .accessibilityLabel("Remote Forward") - Text("Dynamic").tag(PortForwardingRule.Kind.dynamic) - .accessibilityLabel("Dynamic SOCKS5 Proxy") - } - .labelsHidden() - .pickerStyle(.segmented) - .frame(maxWidth: .infinity) + PortForwardingDirectionPicker(kind: $draft.kind) Text(directionHelp).font(.caption).foregroundStyle(.secondary) } Section("SSH Host") { diff --git a/TFM/Features/SFTP/SFTPBrowserView.swift b/TFM/Features/SFTP/SFTPBrowserView.swift index d3978d29..58d8b9b8 100644 --- a/TFM/Features/SFTP/SFTPBrowserView.swift +++ b/TFM/Features/SFTP/SFTPBrowserView.swift @@ -1,5 +1,6 @@ import ListViewKit import SwiftUI +import TermindPresentation import UniformTypeIdentifiers #if canImport(AppKit) @@ -1236,15 +1237,6 @@ struct SFTPBrowserView: View { } private struct SFTPEditPermissionsSheet: View { - private struct AccessRow: Identifiable { - let title: String - let read: UInt32 - let write: UInt32 - let execute: UInt32 - - var id: UInt32 { read } - } - let file: RemoteFile let displayedPath: String let save: (UInt32) async -> Bool @@ -1254,12 +1246,6 @@ private struct SFTPEditPermissionsSheet: View { @State private var draft: SFTPPermissionDraft @State private var isSaving = false - private let accessRows = [ - AccessRow(title: "Owner", read: 0o400, write: 0o200, execute: 0o100), - AccessRow(title: "Group", read: 0o040, write: 0o020, execute: 0o010), - AccessRow(title: "Others", read: 0o004, write: 0o002, execute: 0o001), - ] - init( file: RemoteFile, displayedPath: String, save: @escaping (UInt32) async -> Bool @@ -1337,46 +1323,12 @@ private struct SFTPEditPermissionsSheet: View { private var permissionGrid: some View { VStack(alignment: .leading, spacing: Metrics.Space.m) { Text("File Access").font(.headline) - Grid(horizontalSpacing: Metrics.Space.xxl, verticalSpacing: Metrics.Space.l) { - GridRow { - Color.clear.frame(width: 72, height: 1) - Text("Read") - Text("Write") - Text("Execute") - } - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) - - ForEach(accessRows) { row in - GridRow { - Text(row.title) - .frame(width: 72, alignment: .leading) - .foregroundStyle(.secondary) - permissionToggle(row.read, label: "\(row.title) read") - permissionToggle(row.write, label: "\(row.title) write") - permissionToggle(row.execute, label: "\(row.title) execute") - } - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, Metrics.Space.s) + UnixPermissionGrid(draft: $draft, horizontalSpacing: Metrics.Space.xxl, verticalSpacing: Metrics.Space.l) + .frame(maxWidth: .infinity) + .padding(.vertical, Metrics.Space.s) } } - private func permissionToggle(_ permission: UInt32, label: String) -> some View { - Toggle( - label, - isOn: Binding( - get: { draft.contains(permission) }, - set: { draft.set(permission, enabled: $0) } - ) - ) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) - .accessibilityLabel(label) - } - private func ownershipRow(_ label: LocalizedStringKey, value: UInt32) -> some View { HStack { Text(label).foregroundStyle(.secondary) diff --git a/TFM/Features/SFTP/SFTPPermissions.swift b/TFM/Features/SFTP/SFTPPermissions.swift index 59a7445a..a2b5ac28 100644 --- a/TFM/Features/SFTP/SFTPPermissions.swift +++ b/TFM/Features/SFTP/SFTPPermissions.swift @@ -1,66 +1,3 @@ -import Foundation +import TermindDomain -/// Editable Unix permission bits for the SFTP permissions sheet. -/// -/// The remote attributes also contain a file-type mask. SETSTAT only needs the -/// four chmod digits, so the draft deliberately keeps those bits separate. -nonisolated struct SFTPPermissionDraft: Equatable, Sendable { - static let editableMask: UInt32 = 0o7777 - - let originalMode: UInt32 - private(set) var mode: UInt32 - private(set) var text: String - - init(permissions: UInt32) { - let mode = permissions & Self.editableMask - originalMode = mode - self.mode = mode - text = Self.octalString(for: mode) - } - - var parsedMode: UInt32? { - guard !text.isEmpty else { return nil } - return UInt32(text, radix: 8).map { $0 & Self.editableMask } - } - - var canSave: Bool { - guard let parsedMode else { return false } - return parsedMode != originalMode - } - - func contains(_ permission: UInt32) -> Bool { - mode & permission != 0 - } - - /// Accepts a transient editor value without destroying the last valid mode. - /// This lets the field be cleared while the user types a replacement. - @discardableResult - mutating func accept(_ candidate: String) -> Bool { - guard candidate.count <= 4, - candidate.allSatisfy({ ("0"..."7").contains($0) }) - else { return false } - text = candidate - if let parsedMode { mode = parsedMode } - return true - } - - mutating func normalizeText() { - guard let parsedMode else { return } - mode = parsedMode - text = Self.octalString(for: parsedMode) - } - - mutating func set(_ permission: UInt32, enabled: Bool) { - if enabled { - mode |= permission - } else { - mode &= ~permission - } - mode &= Self.editableMask - text = Self.octalString(for: mode) - } - - static func octalString(for permissions: UInt32) -> String { - String(format: "%04o", permissions & editableMask) - } -} +typealias SFTPPermissionDraft = TermindDomain.SFTPPermissionDraft diff --git a/TFM/Infrastructure/Cloud/VaultSyncPersistence.swift b/TFM/Infrastructure/Cloud/VaultSyncPersistence.swift index e8d14604..bbcdf158 100644 --- a/TFM/Infrastructure/Cloud/VaultSyncPersistence.swift +++ b/TFM/Infrastructure/Cloud/VaultSyncPersistence.swift @@ -1,390 +1,6 @@ -import Foundation -import SwiftData -import TermindDomain import TermindPersistence -nonisolated enum VaultSyncPersistenceError: LocalizedError { - case invalidEnvelope(String) - case unsupportedFormat(Int) - - var errorDescription: String? { - switch self { - case .invalidEnvelope(let recordName): - String(localized: "The iCloud Vault record \(recordName) is invalid.") - case .unsupportedFormat(let version): - String(localized: "This Vault record requires sync format \(version).") - } - } -} - -nonisolated struct VaultRemoteMerge: Sendable { - var accepted: [VaultEnvelope] = [] - var needsUpload: Set = [] -} - -nonisolated struct VaultOutgoingEntry: Sendable { - var envelope: VaultEnvelope - var systemFieldsData: Data? -} - -extension VaultSnapshot { - nonisolated var recordPayloads: [VaultRecordKey: VaultRecordPayload] { - var result: [VaultRecordKey: VaultRecordPayload] = [:] - for value in hosts { result[VaultRecordKey(kind: .host, id: value.id)] = .host(value) } - for value in groups { - result[VaultRecordKey(kind: .hostGroup, id: value.id)] = .hostGroup(value) - } - for value in identities { - result[VaultRecordKey(kind: .identity, id: value.id)] = .identity(value) - } - for value in keys { - result[VaultRecordKey(kind: .sshKey, id: value.id)] = .sshKey(value) - } - for value in knownHosts { - result[VaultRecordKey(kind: .knownHost, id: value.id)] = .knownHost(value) - } - for value in snippets { - result[VaultRecordKey(kind: .snippet, id: value.id)] = .snippet(value) - } - for value in snippetPackages { - result[VaultRecordKey(kind: .snippetPackage, id: value.id)] = .snippetPackage(value) - } - for value in portForwardings { - result[VaultRecordKey(kind: .portForwarding, id: value.id)] = .portForwarding(value) - } - return result - } - - nonisolated mutating func apply(_ envelope: VaultEnvelope) throws { - guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { - throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) - } - guard envelope.isValid else { - throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) - } - - if envelope.isDeleted { - remove(envelope.key) - return - } - guard let payload = envelope.payload else { - throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) - } - switch payload { - case .host(let value): upsert(value, in: &hosts) - case .hostGroup(let value): upsert(value, in: &groups) - case .identity(let value): upsert(value, in: &identities) - case .sshKey(let value): upsert(value, in: &keys) - case .knownHost(let value): upsert(value, in: &knownHosts) - case .snippet(let value): upsert(value, in: &snippets) - case .snippetPackage(let value): upsert(value, in: &snippetPackages) - case .portForwarding(let value): upsert(value, in: &portForwardings) - } - } - - private nonisolated mutating func remove(_ key: VaultRecordKey) { - switch key.kind { - case .host: hosts.removeAll { $0.id == key.id } - case .hostGroup: groups.removeAll { $0.id == key.id } - case .identity: identities.removeAll { $0.id == key.id } - case .sshKey: keys.removeAll { $0.id == key.id } - case .knownHost: knownHosts.removeAll { $0.id == key.id } - case .snippet: snippets.removeAll { $0.id == key.id } - case .snippetPackage: snippetPackages.removeAll { $0.id == key.id } - case .portForwarding: portForwardings.removeAll { $0.id == key.id } - } - } - - private nonisolated func upsert( - _ value: Value, in values: inout [Value] - ) where Value.ID == UUID { - if let index = values.firstIndex(where: { $0.id == value.id }) { - values[index] = value - } else { - values.append(value) - } - } -} - -@MainActor -final class VaultSyncJournal { - private let context: ModelContext - - init(context: ModelContext) { self.context = context } - - func state() throws -> PersistentVaultSyncState { - var descriptor = FetchDescriptor() - descriptor.fetchLimit = 1 - if let existing = try context.fetch(descriptor).first { return existing } - let state = PersistentVaultSyncState() - context.insert(state) - return state - } - - @discardableResult - func bootstrapIfNeeded(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { - let state = try state() - guard !state.bootstrapCompleted else { return .empty } - let existing = try entriesByName() - var changed = Set() - for payload in snapshot.recordPayloads.values { - let key = payload.key - guard existing[key.recordName] == nil else { continue } - let envelope = VaultEnvelope( - key: key, version: .baseline(writerID: state.installationID), payload: payload - ) - context.insert(try PersistentVaultSyncEntry(envelope: envelope, needsUpload: true)) - changed.insert(key) - } - state.bootstrapCompleted = true - return VaultMutationSet(keys: changed) - } - - func recordLocalChanges( - from oldSnapshot: VaultSnapshot, to newSnapshot: VaultSnapshot, - now: Date = .now - ) throws -> VaultMutationSet { - let state = try state() - let old = oldSnapshot.recordPayloads - let new = newSnapshot.recordPayloads - var entries = try entriesByName() - var changed = Set() - - for (key, payload) in new where old[key] != payload { - let row = entries[key.recordName] - let envelope = VaultEnvelope( - key: key, - version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), - payload: payload - ) - try store(envelope, needsUpload: true, in: &entries) - changed.insert(key) - } - - for key in old.keys where new[key] == nil { - let row = entries[key.recordName] - let envelope = VaultEnvelope( - key: key, - version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), - isDeleted: true - ) - try store(envelope, needsUpload: true, in: &entries) - changed.insert(key) - } - return VaultMutationSet(keys: changed) - } - - func pendingEnvelopes() throws -> [VaultEnvelope] { - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.needsUpload } - ) - ).compactMap(\.envelope) - } - - func outgoingEntry(for key: VaultRecordKey) throws -> VaultOutgoingEntry? { - guard let row = try entry(recordName: key.recordName), let envelope = row.envelope else { - return nil - } - return VaultOutgoingEntry(envelope: envelope, systemFieldsData: row.systemFieldsData) - } - - func envelope(for key: VaultRecordKey) throws -> VaultEnvelope? { - try entry(recordName: key.recordName)?.envelope - } - - func mergeDecision(for remote: [VaultEnvelope]) throws -> VaultRemoteMerge { - var result = VaultRemoteMerge() - let entries = try entriesByName() - for envelope in remote { - guard envelope.formatVersion <= VaultEnvelope.currentFormatVersion else { - throw VaultSyncPersistenceError.unsupportedFormat(envelope.formatVersion) - } - guard envelope.isValid else { - throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) - } - guard let local = entries[envelope.key.recordName]?.envelope else { - result.accepted.append(envelope) - continue - } - if local.version < envelope.version { - result.accepted.append(envelope) - } else if envelope.version < local.version { - result.needsUpload.insert(local.key) - } else if local != envelope { - let localData = VaultSyncCodec.encode(local) - let remoteData = VaultSyncCodec.encode(envelope) - if localData.lexicographicallyPrecedes(remoteData) { - result.accepted.append(envelope) - } else { - result.needsUpload.insert(local.key) - } - } - } - return result - } - - func acceptRemote(_ envelopes: [VaultEnvelope]) throws { - var entries = try entriesByName() - for envelope in envelopes { - try store(envelope, needsUpload: false, in: &entries) - } - } - - func markNeedsUpload(_ keys: Set) throws { - let entries = try entriesByName() - for key in keys { entries[key.recordName]?.needsUpload = true } - } - - func markUploaded(_ key: VaultRecordKey, version: VaultVersion) throws { - guard let row = try entry(recordName: key.recordName), row.envelope?.version == version else { - return - } - row.needsUpload = false - } - - func updateSystemFields(_ data: Data?, for key: VaultRecordKey) throws { - try entry(recordName: key.recordName)?.systemFieldsData = data - } - - func quarantine(recordName: String, payloadData: Data, reason: String) throws { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.recordName == recordName } - ) - descriptor.fetchLimit = 1 - if let row = try context.fetch(descriptor).first { - row.payloadData = payloadData - row.reason = reason - row.receivedAt = .now - } else { - context.insert( - PersistentVaultSyncQuarantine( - recordName: recordName, payloadData: payloadData, reason: reason - )) - } - } - - func clearQuarantine(recordName: String) throws { - try context.delete( - model: PersistentVaultSyncQuarantine.self, - where: #Predicate { $0.recordName == recordName } - ) - } - - func firstQuarantineReason() throws -> String? { - var descriptor = FetchDescriptor( - sortBy: [SortDescriptor(\.receivedAt, order: .reverse)] - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first?.reason - } - - func recordCurrentSnapshot(_ snapshot: VaultSnapshot, now: Date = .now) throws - -> VaultMutationSet - { - let state = try state() - let current = snapshot.recordPayloads - var entries = try entriesByName() - var changed = Set() - - for (key, payload) in current { - let row = entries[key.recordName] - guard row?.envelope?.payload != payload || row?.envelope?.isDeleted != false else { - continue - } - let envelope = VaultEnvelope( - key: key, - version: .next(after: row?.envelope?.version, writerID: state.installationID, now: now), - payload: payload - ) - try store(envelope, needsUpload: true, in: &entries) - changed.insert(key) - } - - for row in entries.values { - guard let envelope = row.envelope, !envelope.isDeleted, - current[envelope.key] == nil - else { continue } - let tombstone = VaultEnvelope( - key: envelope.key, - version: .next(after: envelope.version, writerID: state.installationID, now: now), - isDeleted: true - ) - row.apply(tombstone, needsUpload: true) - changed.insert(envelope.key) - } - return VaultMutationSet(keys: changed) - } - - func markAllLiveRecordsNeedsUploadForNewAccount() throws { - for row in try context.fetch(FetchDescriptor()) { - guard let envelope = row.envelope else { continue } - if envelope.isDeleted { - context.delete(row) - } else { - row.needsUpload = true - row.systemFieldsData = nil - } - } - let state = try state() - state.engineStateData = nil - state.lastSuccessfulSync = nil - } - - func markAllEntriesNeedsUploadAfterZoneReset() throws { - for row in try context.fetch(FetchDescriptor()) { - row.needsUpload = true - row.systemFieldsData = nil - } - let state = try state() - state.engineStateData = nil - state.lastSuccessfulSync = nil - } - - /// CloudKit record system fields include environment-specific change tags. - /// Keep every local envelope, but discard those tags and the serialized - /// engine state before moving between Development and Production. - func markAllEntriesNeedsUploadAfterEnvironmentChange() throws { - try markAllEntriesNeedsUploadAfterZoneReset() - } - - private func entry(recordName: String) throws -> PersistentVaultSyncEntry? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.recordName == recordName } - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first - } - - private func store( - _ envelope: VaultEnvelope, needsUpload: Bool, - in entries: inout [String: PersistentVaultSyncEntry] - ) throws { - if let row = entries[envelope.key.recordName] { - row.apply(envelope, needsUpload: needsUpload) - } else { - let row = try PersistentVaultSyncEntry( - envelope: envelope, needsUpload: needsUpload - ) - context.insert(row) - entries[envelope.key.recordName] = row - } - } - - private func entriesByName() throws -> [String: PersistentVaultSyncEntry] { - Dictionary( - try context.fetch(FetchDescriptor()).map { - ($0.recordName, $0) - }, - uniquingKeysWith: { first, duplicate in - if let firstVersion = first.envelope?.version, - let duplicateVersion = duplicate.envelope?.version, - firstVersion < duplicateVersion - { - return duplicate - } - return first - } - ) - } -} +typealias VaultSyncPersistenceError = SharedVaultSyncPersistenceError +typealias VaultRemoteMerge = SharedVaultRemoteMerge +typealias VaultOutgoingEntry = SharedVaultOutgoingEntry +typealias VaultSyncJournal = SharedVaultSyncJournal diff --git a/TFM/Infrastructure/Commerce/TrialClock.swift b/TFM/Infrastructure/Commerce/TrialClock.swift index adcd0035..3d6e260a 100644 --- a/TFM/Infrastructure/Commerce/TrialClock.swift +++ b/TFM/Infrastructure/Commerce/TrialClock.swift @@ -1,16 +1,3 @@ -import Foundation -import TermindDomain +import TermindInfrastructure -/// This watermark can only shorten a verified trial, never grant one. It is a -/// best-effort clock rollback guard, not a second store of purchase eligibility. -struct TrialClock { - let defaults: UserDefaults? - - func effectiveNow(for trial: ProTrial, now: Date) -> Date { - let key = "commerce.pro.trial-clock.\(trial.originalID)" - let lastObserved = defaults?.object(forKey: key) as? Date ?? trial.purchasedAt - let effective = max(now, lastObserved, trial.purchasedAt) - defaults?.set(effective, forKey: key) - return effective - } -} +typealias TrialClock = TermindInfrastructure.TrialClock diff --git a/TFM/Infrastructure/Database/AIPersistence.swift b/TFM/Infrastructure/Database/AIPersistence.swift index 4b7e1f16..ed3ce564 100644 --- a/TFM/Infrastructure/Database/AIPersistence.swift +++ b/TFM/Infrastructure/Database/AIPersistence.swift @@ -1,116 +1,3 @@ -import AgentKit -import Foundation -import SwiftData -import TermindDomain import TermindPersistence -@MainActor -final class AIPersistence { - private let context: ModelContext - - init(context: ModelContext) { - self.context = context - } - - func load() throws -> AIConfigurationSnapshot { - try load(preservingUnresolvedReferences: false) - } - - func load(preservingUnresolvedReferences: Bool) throws -> AIConfigurationSnapshot { - let modelRows = try context.fetch( - FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) - ) - let modelsByProvider = Dictionary(grouping: modelRows, by: \.providerID) - let providerRows = try context.fetch( - FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) - ) - var seenProviders: Set = [] - var endpoints: [ModelProvider.ID: ProviderEndpoint] = [:] - let providers = providerRows.compactMap { row -> ModelProvider? in - guard seenProviders.insert(row.id).inserted else { return nil } - var seenModels: Set = [] - let models: [AIModel] = (modelsByProvider[row.id] ?? []).compactMap { modelRow -> AIModel? in - guard seenModels.insert(modelRow.modelID).inserted else { return nil } - return modelRow.value - } - endpoints[row.id] = row.endpoint - return row.value(models: models) - } - - func valid(_ ref: AIModelRef) -> Bool { - providers.first(where: { $0.id == ref.modelProviderID })?.model(ref.modelID) != nil - } - - var roles: [AIRole: AIModelRef] = [:] - for row in try context.fetch(FetchDescriptor()) { - guard let role = AIRole(rawValue: row.role) else { continue } - let ref = AIModelRef(modelProviderID: row.providerID, modelID: row.modelID) - if preservingUnresolvedReferences || valid(ref) { roles[role] = ref } - } - - let selectionRow = try context.fetch(FetchDescriptor()).first - let lastSelectedModel: AIModelRef? = { - guard let providerID = selectionRow?.providerID, - let modelID = selectionRow?.modelID - else { return nil } - let ref = AIModelRef(modelProviderID: providerID, modelID: modelID) - return preservingUnresolvedReferences || valid(ref) ? ref : nil - }() - - let mcpRows = try context.fetch( - FetchDescriptor(sortBy: [SortDescriptor(\.sortIndex)]) - ) - var seenMCPServers: Set = [] - let mcpServers = mcpRows.compactMap { row -> MCPServer? in - guard seenMCPServers.insert(row.id).inserted else { return nil } - return row.value - } - - return AIConfigurationSnapshot( - providers: providers, - endpoints: endpoints, - roles: roles, - lastSelectedModel: lastSelectedModel, - mcpServers: mcpServers - ) - } - - func save(_ snapshot: AIConfigurationSnapshot) throws { - try context.commit { try replace(snapshot) } - } - - /// Stages rows in the caller-owned transaction. - func replace(_ snapshot: AIConfigurationSnapshot) throws { - try context.delete(model: PersistentAIModel.self) - try context.delete(model: PersistentModelProvider.self) - try context.delete(model: PersistentAIRoleAssignment.self) - try context.delete(model: PersistentAISelection.self) - try context.delete(model: PersistentMCPServer.self) - - for (providerIndex, provider) in snapshot.providers.enumerated() { - // A provider with no recorded endpoint is read back as - // `Automatic` over its composed URL, which resolves to the same - // format it already has — see `AIConfigurationModel.endpoints`. - let endpoint = - snapshot.endpoints[provider.id] ?? ProviderEndpoint(url: provider.inferenceURL) - context.insert( - PersistentModelProvider(provider, endpoint: endpoint, sortIndex: providerIndex) - ) - for (modelIndex, model) in provider.models.enumerated() { - context.insert( - PersistentAIModel(model, providerID: provider.id, sortIndex: modelIndex) - ) - } - } - for role in AIRole.allCases { - guard let ref = snapshot.roles[role] else { continue } - context.insert(PersistentAIRoleAssignment(role: role, ref: ref)) - } - context.insert(PersistentAISelection(snapshot.lastSelectedModel)) - for (serverIndex, server) in snapshot.mcpServers.enumerated() { - context.insert(PersistentMCPServer(server, sortIndex: serverIndex)) - } - } -} - -extension AIPersistence: AIConfigurationPersisting {} +typealias AIPersistence = SharedAIPersistence diff --git a/TFM/Infrastructure/Database/AgentPersistence.swift b/TFM/Infrastructure/Database/AgentPersistence.swift index 3086dc82..13f2815e 100644 --- a/TFM/Infrastructure/Database/AgentPersistence.swift +++ b/TFM/Infrastructure/Database/AgentPersistence.swift @@ -1,658 +1,3 @@ -import AgentKit -import Foundation -import SwiftData import TermindPersistence -@MainActor -final class SwiftDataAgentRunRepository: AgentRunPersisting, Sendable { - private let context: ModelContext - private var deletedConversationIDs = Set() - private var deletedRunIDs = Set() - - init(context: ModelContext) { - self.context = context - interruptUnfinishedRuns() - } - - @MainActor - func save(_ snapshot: AgentRunSnapshot) async throws { - guard !deletedConversationIDs.contains(snapshot.conversationID) else { - deletedRunIDs.insert(snapshot.id) - return - } - let runID = snapshot.id - let runDescriptor = FetchDescriptor(predicate: #Predicate { $0.id == runID }) - if let row = try context.fetch(runDescriptor).first { - row.update(snapshot) - } else { - context.insert(PersistentAgentRun(snapshot)) - } - - let conversationID = snapshot.conversationID - let conversationDescriptor = FetchDescriptor( - predicate: #Predicate { $0.id == conversationID } - ) - // No title seeding here. `title` means "the generated summary", and - // empty means there is not one yet — which is what lets the UI decide to - // fall back to the opening line rather than having that line frozen into - // storage as though it were a name someone chose. - if let conversation = try context.fetch(conversationDescriptor).first { - conversation.updatedAt = .now - } else { - context.insert( - PersistentAgentConversation( - id: conversationID, title: "", createdAt: snapshot.startedAt - )) - } - - // Matched on message id, not on this run's id. A conversation's earlier - // turns are replayed into every later run, so filtering by `runID` made - // each save re-insert the whole history — colliding with the unique - // constraint and rewriting rows that were already correct. - let durableMessages = snapshot.messages.filter { !$0.isCompaction } - let ids = Set(durableMessages.map(\.id)) - let stored = try context.fetch( - FetchDescriptor( - predicate: #Predicate { ids.contains($0.id) } - )) - let storedIDs = Set(stored.map(\.id)) - var inserted = false - for (sequence, message) in durableMessages.enumerated() - where !storedIDs.contains(message.id) { - context.insert( - PersistentAgentMessage( - message, runID: runID, sequence: sequence - )) - insertContextAttachments(for: message) - inserted = true - } - guard inserted || context.hasChanges else { return } - try context.save() - } - - @MainActor - func persistCompaction( - _ record: AgentCompactionRecord, conversationID: UUID - ) async throws { - guard !deletedConversationIDs.contains(conversationID) else { - throw AgentConversationPersistenceError.conversationNotFound - } - let conversations = try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.id == conversationID } - )) - guard conversations.first != nil else { - throw AgentConversationPersistenceError.conversationNotFound - } - if let boundary = record.compactedThroughMessageID { - let runIDs = Set( - try context.fetch(FetchDescriptor()) - .filter { $0.conversationID == conversationID }.map(\.id)) - let rows = try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.id == boundary } - )) - guard rows.contains(where: { runIDs.contains($0.runID) }) else { - throw AgentConversationPersistenceError.messageNotFound - } - } - let recordID = record.id - let existing = try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.id == recordID } - )) - guard existing.isEmpty else { return } - context.insert(PersistentAgentCompaction(record, conversationID: conversationID)) - try context.savePendingChanges() - } - - @MainActor - func record(_ event: AgentEvent, runID: UUID) async throws { - guard !deletedRunIDs.contains(runID) else { return } - switch event { - case .toolProposed(let invocation, _): - try upsertInvocation(invocation, state: "proposed") - case .toolStarted(let invocation): - try upsertInvocation(invocation, state: "started") - case .toolFinished(let invocation, let result): - try upsertInvocation(invocation, state: "finished") - context.insert(PersistentAgentToolResult(result, invocation: invocation)) - case .reviewStarted(let request): - context.insert( - PersistentAgentReviewRecord( - runID: runID, invocationID: request.invocation.id, decision: nil - )) - case .reviewFinished(let request, let decision): - context.insert( - PersistentAgentReviewRecord( - runID: runID, invocationID: request.invocation.id, decision: decision - )) - case .reviewFailed(let request, let reason, let timedOut): - context.insert( - PersistentAgentReviewRecord( - runID: runID, invocationID: request.invocation.id, - failure: reason, timedOut: timedOut - )) - default: - // Nothing to write, so nothing to save. `AgentRuntime` already - // filters on `isDurable`; this is the second half of the same - // guarantee, for any caller that does not. - return - } - try context.save() - } - - @MainActor - func loadConversations() async throws -> [StoredAgentConversation] { - let conversations = try context.fetch( - FetchDescriptor( - sortBy: [SortDescriptor(\.updatedAt, order: .reverse)] - )) - let orderRows = try context.fetch(FetchDescriptor()) - let sortIndexByConversation = Dictionary( - uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0.sortIndex) } - ) - let runs = try context.fetch(FetchDescriptor()) - let messages = try context.fetch(FetchDescriptor()) - let compactionRows = try context.fetch(FetchDescriptor()) - let attachmentRows = try context.fetch( - FetchDescriptor() - ) - let attachmentsByMessage = Dictionary( - uniqueKeysWithValues: attachmentRows.map { ($0.messageID, $0.attachments) } - ) - let invocations = try context.fetch(FetchDescriptor()) - let results = try context.fetch(FetchDescriptor()) - let reviews = try context.fetch(FetchDescriptor()) - return conversations.map { conversation in - let matchingRuns = runs.filter { $0.conversationID == conversation.id } - .sorted { $0.startedAt < $1.startedAt } - let runIDs = Set(matchingRuns.map(\.id)) - // Ordered by run first, then by time within it. Sorting the whole - // conversation on `createdAt` alone let two runs interleave whenever - // their message timestamps were close, which reorders a transcript - // the model is about to be shown. - let runOrder = Dictionary( - uniqueKeysWithValues: matchingRuns.enumerated().map { ($0.element.id, $0.offset) } - ) - let transcript = messages.filter { runIDs.contains($0.runID) } - .sorted { lhs, rhs in - let left = runOrder[lhs.runID] ?? 0 - let right = runOrder[rhs.runID] ?? 0 - if left != right { return left < right } - if lhs.sequence != rhs.sequence { return lhs.sequence < rhs.sequence } - if lhs.createdAt != rhs.createdAt { return lhs.createdAt < rhs.createdAt } - // Transcript grammar gives equal-position, equal-time rows - // a stable and useful final ordering. - let rank = { (role: String) -> Int in - switch AgentTranscriptRole(rawValue: role) { - case .user: 0 - case .assistant: 1 - case .tool: 2 - case nil: 3 - } - } - return rank(lhs.role) < rank(rhs.role) - } - .compactMap { row -> AgentTranscriptMessage? in - guard let role = AgentTranscriptRole(rawValue: row.role) else { return nil } - let calls = - (try? JSONDecoder().decode( - [AgentToolCall].self, from: row.toolCallsData - )) ?? [] - let images = - (try? JSONDecoder().decode( - [AgentImageAttachment].self, from: row.imagesData - )) ?? [] - let contextAttachments = attachmentsByMessage[row.id] ?? [] - let reasoning = - (try? JSONDecoder().decode( - [AgentReasoningBlock].self, from: row.reasoningData - )) ?? [] - return AgentTranscriptMessage( - id: row.id, role: role, text: row.text, - authoredText: row.authoredText, images: images, - contextAttachments: contextAttachments, - reasoning: reasoning, - toolCalls: calls, - toolCallID: row.toolCallID, toolName: row.toolName, - isError: row.isError, isCompaction: row.isCompaction, - createdAt: row.createdAt - ) - } - let messageRunIDs = Dictionary( - uniqueKeysWithValues: messages.filter { runIDs.contains($0.runID) } - .map { ($0.id, $0.runID) } - ) - let state = - matchingRuns.last.flatMap { AgentRunState(rawValue: $0.state) } - ?? .completed - let cards = invocations.filter { runIDs.contains($0.runID) } - .sorted { $0.createdAt < $1.createdAt } - .compactMap { storedToolCard($0, results: results, reviews: reviews) } - let projection = AgentCompactionProjection.restore( - messages: Self.recovering( - transcript, - results: results.filter { runIDs.contains($0.runID) }, - invocations: invocations.filter { runIDs.contains($0.runID) } - ), - records: compactionRows.filter { $0.conversationID == conversation.id } - .map(\.value) - ) - return StoredAgentConversation( - id: conversation.id, - messages: projection.messages, - modelTranscript: projection.modelTranscript, - compactions: projection.records, - messageRunIDs: messageRunIDs, - runs: matchingRuns.compactMap { run in - guard let state = AgentRunState(rawValue: run.state) else { return nil } - return AgentRunSummary( - id: run.id, state: state, startedAt: run.startedAt, - finishedAt: run.finishedAt - ) - }, - toolCards: cards, - lastRunState: state, title: conversation.title, - metadata: StoredAgentConversationMetadata( - customTitle: conversation.customTitle, - isPinned: conversation.isPinned, - sortIndex: sortIndexByConversation[conversation.id], - archivedAt: conversation.archivedAt, - createdAt: conversation.createdAt, - updatedAt: conversation.updatedAt - ) - ) - } - } - - @MainActor - func setTitle(_ title: String, conversationID: UUID) async throws { - guard !deletedConversationIDs.contains(conversationID) else { return } - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == conversationID } - ) - // A title is model output about remote-influenced text, so it goes - // through the same redaction every other stored string does. - guard let row = try context.fetch(descriptor).first else { return } - row.title = AgentPersistenceRedactor.visibleText(String(title.prefix(80))) - row.updatedAt = .now - try context.save() - } - - @MainActor - func setConversationMetadata( - _ metadata: StoredAgentConversationMetadata, conversationID: UUID - ) async throws { - guard !deletedConversationIDs.contains(conversationID) else { - throw AgentConversationPersistenceError.conversationNotFound - } - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == conversationID } - ) - guard let row = try context.fetch(descriptor).first else { - throw AgentConversationPersistenceError.conversationNotFound - } - row.customTitle = AgentPersistenceRedactor.visibleText( - String(metadata.customTitle.prefix(80)) - ) - row.isPinned = metadata.isPinned - row.archivedAt = metadata.archivedAt - row.updatedAt = metadata.updatedAt - try context.savePendingChanges() - } - - @MainActor - func reorderConversations(_ updates: [StoredAgentConversationOrder]) async throws { - let ids = Set(updates.map(\.id)) - guard updates.count == ids.count else { - throw AgentConversationPersistenceError.conversationNotFound - } - let rows = try context.fetch( - FetchDescriptor( - predicate: #Predicate { ids.contains($0.id) } - )) - guard rows.count == ids.count else { - throw AgentConversationPersistenceError.conversationNotFound - } - let updatesByID = Dictionary(uniqueKeysWithValues: updates.map { ($0.id, $0) }) - let orderRows = try context.fetch( - FetchDescriptor( - predicate: #Predicate { ids.contains($0.conversationID) } - )) - var orderByID = Dictionary( - uniqueKeysWithValues: orderRows.map { ($0.conversationID, $0) } - ) - for row in rows { - guard let update = updatesByID[row.id] else { continue } - row.isPinned = update.isPinned - if let order = orderByID[row.id] { - order.sortIndex = update.sortIndex - } else { - let order = PersistentAgentConversationOrder( - conversationID: row.id, sortIndex: update.sortIndex - ) - context.insert(order) - orderByID[row.id] = order - } - } - try context.savePendingChanges() - } - - @MainActor - func truncateConversation( - _ id: UUID, fromUserMessageID messageID: UUID, - replacingWith replacement: AgentTranscriptMessage - ) async throws { - let allRuns = try context.fetch(FetchDescriptor()) - let runs = allRuns.filter { $0.conversationID == id } - .sorted { $0.startedAt < $1.startedAt } - let allMessages = try context.fetch(FetchDescriptor()) - guard - let target = allMessages.first(where: { message in - message.id == messageID && runs.contains { $0.id == message.runID } - }), - let targetIndex = runs.firstIndex(where: { $0.id == target.runID }) - else { throw AgentConversationPersistenceError.messageNotFound } - - let removedRuns = Array(runs[targetIndex...]) - let removedRunIDs = Set(removedRuns.map(\.id)) - let removedMessageIDs = Set(allMessages.filter { removedRunIDs.contains($0.runID) }.map(\.id)) - deletedRunIDs.formUnion(removedRunIDs) - let earlierRunIDs = Set(runs[..()) - where row.conversationID == id - && row.compactedThroughMessageID.map(removedMessageIDs.contains) == true - { - context.delete(row) - } - - let replacementRun = AgentRunSnapshot( - conversationID: id, state: .completed, - permissionMode: .askForApproval, messages: [replacement], - startedAt: replacement.createdAt, finishedAt: replacement.createdAt - ) - context.insert(PersistentAgentRun(replacementRun)) - context.insert( - PersistentAgentMessage( - replacement, runID: replacementRun.id, sequence: 0 - )) - insertContextAttachments(for: replacement) - - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - if let conversation = try context.fetch(descriptor).first { - if removesOpeningTurn { conversation.title = "" } - conversation.updatedAt = .now - } - try context.save() - } catch { - context.rollback() - deletedRunIDs.subtract(removedRunIDs) - throw error - } - } - - @MainActor - func createConversation(_ value: StoredAgentConversation) async throws { - guard !deletedConversationIDs.contains(value.id) else { - throw AgentConversationPersistenceError.conversationAlreadyExists - } - let conversationID = value.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == conversationID } - ) - guard try context.fetch(descriptor).isEmpty else { - throw AgentConversationPersistenceError.conversationAlreadyExists - } - - let metadata = value.metadata - let conversation = PersistentAgentConversation( - id: value.id, title: value.title, - createdAt: metadata.createdAt, updatedAt: metadata.updatedAt - ) - conversation.customTitle = metadata.customTitle - conversation.isPinned = metadata.isPinned - conversation.archivedAt = metadata.archivedAt - try context.commit { - context.insert(conversation) - if let sortIndex = metadata.sortIndex { - context.insert( - PersistentAgentConversationOrder( - conversationID: value.id, sortIndex: sortIndex - )) - } - - let summaries = - value.runs.isEmpty - ? [ - AgentRunSummary( - id: UUID(), state: value.lastRunState, - startedAt: metadata.createdAt, finishedAt: metadata.updatedAt - ) - ] - : value.runs.sorted { $0.startedAt < $1.startedAt } - for summary in summaries { - context.insert( - PersistentAgentRun( - AgentRunSnapshot( - id: summary.id, conversationID: value.id, state: summary.state, - permissionMode: .askForApproval, messages: [], - startedAt: summary.startedAt, finishedAt: summary.finishedAt - ))) - } - let validRunIDs = Set(summaries.map(\.id)) - let fallbackRunID = summaries[0].id - for (sequence, message) in value.messages.enumerated() { - guard !message.isCompaction else { continue } - context.insert( - PersistentAgentMessage( - message, - runID: value.messageRunIDs[message.id].flatMap { - validRunIDs.contains($0) ? $0 : nil - } ?? fallbackRunID, - sequence: sequence - )) - insertContextAttachments(for: message) - } - for record in value.compactions { - context.insert(PersistentAgentCompaction(record, conversationID: value.id)) - } - for card in value.toolCards { - var invocation = card.invocation - if !validRunIDs.contains(invocation.runID) { - invocation.runID = fallbackRunID - } - context.insert(PersistentAgentToolInvocation(invocation, state: card.state)) - if let result = card.result { - context.insert(PersistentAgentToolResult(result, invocation: invocation)) - } - if let review = card.review { - context.insert( - PersistentAgentReviewRecord( - runID: invocation.runID, invocationID: invocation.id, decision: review - )) - } - } - } - } - - @MainActor - func deleteConversation(_ id: UUID) async throws { - deletedConversationIDs.insert(id) - let runs = try context.fetch(FetchDescriptor()) - .filter { $0.conversationID == id } - let runIDs = Set(runs.map(\.id)) - deletedRunIDs.formUnion(runIDs) - - try deleteGraph(runIDs: runIDs, runs: runs) - - let conversations = try context.fetch(FetchDescriptor()) - for row in conversations where row.id == id { context.delete(row) } - let orderRows = try context.fetch(FetchDescriptor()) - for row in orderRows where row.conversationID == id { context.delete(row) } - for row in try context.fetch(FetchDescriptor()) - where row.conversationID == id { context.delete(row) } - try context.save() - } - - private func deleteGraph(runIDs: Set, runs: [PersistentAgentRun]) throws { - for row in try context.fetch(FetchDescriptor()) - where runIDs.contains(row.runID) { context.delete(row) } - for row in try context.fetch(FetchDescriptor()) - where runIDs.contains(row.runID) { context.delete(row) } - for row in try context.fetch(FetchDescriptor()) - where runIDs.contains(row.runID) { context.delete(row) } - let messages = try context.fetch(FetchDescriptor()) - .filter { runIDs.contains($0.runID) } - let messageIDs = Set(messages.map(\.id)) - for row in try context.fetch(FetchDescriptor()) - where messageIDs.contains(row.messageID) { context.delete(row) } - for row in messages { context.delete(row) } - for row in runs { context.delete(row) } - } - - private func insertContextAttachments(for message: AgentTranscriptMessage) { - guard !message.contextAttachments.isEmpty else { return } - context.insert( - PersistentAgentContextAttachments( - messageID: message.id, attachments: message.contextAttachments - )) - } - - /// Puts back tool results that were recorded but never made it into the - /// transcript. - /// - /// The two are written on different schedules: a result row lands as its - /// tool finishes, while its transcript row waits for the run's next - /// snapshot save. A process killed in between — force quit, crash, the OS - /// reclaiming the app — leaves the call answered in storage and unanswered - /// in history, and an assistant turn with an unanswered call is rejected - /// outright on the next request. - /// - /// `AgentUnansweredToolCallRepair` would invent a placeholder for the same - /// gap, and does for anything still missing after this. Doing it here first - /// is what gives the model the output the tool actually produced instead of - /// being told the outcome is unknown when it is sitting in the database. - static func recovering( - _ transcript: [AgentTranscriptMessage], - results: [PersistentAgentToolResult], - invocations: [PersistentAgentToolInvocation] - ) -> [AgentTranscriptMessage] { - guard transcript.contains(where: { $0.role == .assistant && !$0.toolCalls.isEmpty }) - else { return transcript } - - let names = Dictionary( - invocations.map { ($0.callID, $0.toolName) }, uniquingKeysWith: { first, _ in first } - ) - var recovered: [AgentTranscriptMessage] = [] - recovered.reserveCapacity(transcript.count) - var index = transcript.startIndex - while index < transcript.endIndex { - let message = transcript[index] - recovered.append(message) - index += 1 - guard message.role == .assistant, !message.toolCalls.isEmpty else { continue } - - var answered: Set = [] - while index < transcript.endIndex, transcript[index].role == .tool { - if let id = transcript[index].toolCallID { answered.insert(id) } - recovered.append(transcript[index]) - index += 1 - } - for call in message.toolCalls where !answered.contains(call.id) { - // The last recorded one: a call id is unique to its invocation, - // so more than one row can only mean a rewritten result. - guard let stored = results.last(where: { $0.callID == call.id }) else { continue } - recovered.append( - AgentTranscriptMessage( - role: .tool, text: stored.content, toolCallID: call.id, - toolName: names[call.id] ?? call.name, isError: stored.isError - )) - } - } - return recovered - } - - private func upsertInvocation( - _ invocation: AgentToolInvocation, state: String - ) throws { - let id = invocation.id - let fetch = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - if let row = try context.fetch(fetch).first { - let rank = ["proposed": 0, "started": 1, "finished": 2] - if rank[state, default: 0] > rank[row.state, default: 0] { row.state = state } - if row.sourceMessageID == nil { row.sourceMessageID = invocation.sourceMessageID } - } else { - context.insert(PersistentAgentToolInvocation(invocation, state: state)) - } - } - - private func storedToolCard( - _ row: PersistentAgentToolInvocation, - results: [PersistentAgentToolResult], - reviews: [PersistentAgentReviewRecord] - ) -> StoredAgentToolCard? { - guard let arguments = try? AgentJSONValue.decode(row.argumentsData) else { return nil } - let invocation = AgentToolInvocation( - id: row.id, runID: row.runID, - call: AgentToolCall(id: row.callID, name: row.toolName, arguments: arguments), - sourceMessageID: row.sourceMessageID, - targetHostID: row.targetHostID, createdAt: row.createdAt - ) - let result = results.last(where: { $0.invocationID == row.id }).map { stored in - AgentToolResult( - callID: stored.callID, content: stored.content, - isError: stored.isError, isTruncated: stored.isTruncated, - metadata: (try? JSONDecoder().decode( - [String: AgentJSONValue].self, from: stored.metadataData - )) ?? [:] - ) - } - let descriptor = AgentToolDescriptor( - name: row.toolName, summary: row.toolName, - inputSchema: .object(["type": .string("object")]), - target: row.targetHostID == nil ? .local : .host, - safety: .requiresAuthorization, presentation: result?.toolPresentation - ) - let review = reviews.filter { $0.invocationID == row.id } - .sorted { $0.createdAt < $1.createdAt } - .last.flatMap { stored -> SecurityReviewDecision? in - guard let verdict = stored.verdict.flatMap(SecurityReviewVerdict.init), - let risk = stored.risk.flatMap(SecurityReviewRisk.init), - let authorization = stored.userAuthorization.flatMap(SecurityReviewAuthorization.init) - else { return nil } - return SecurityReviewDecision( - verdict: verdict, risk: risk, userAuthorization: authorization, - reason: stored.reason ?? "" - ) - } - return StoredAgentToolCard( - invocation: invocation, descriptor: descriptor, state: row.state, - result: result, review: review - ) - } - - private func interruptUnfinishedRuns() { - do { - let rows = try context.fetch(FetchDescriptor()) - var changed = false - for row in rows { - guard let state = AgentRunState(rawValue: row.state), !state.isTerminal else { continue } - row.state = AgentRunState.interrupted.rawValue - row.finishedAt = .now - changed = true - } - if changed { try context.save() } - } catch { context.rollback() } - } -} +typealias SwiftDataAgentRunRepository = SharedAgentRunRepository diff --git a/TFM/Infrastructure/Database/HostPersistence.swift b/TFM/Infrastructure/Database/HostPersistence.swift index 8b977fb6..b539f9bc 100644 --- a/TFM/Infrastructure/Database/HostPersistence.swift +++ b/TFM/Infrastructure/Database/HostPersistence.swift @@ -1,143 +1,3 @@ -import Foundation -import SwiftData -import TermindDomain import TermindPersistence -@MainActor -final class HostPersistence { - static let schema = TermindPersistence.schema - - let context: ModelContext - - init(context: ModelContext) { - self.context = context - } - - func load() throws -> VaultSnapshot { - let snapshot = try fetchSnapshot() - _ = try VaultSyncJournal(context: context).bootstrapIfNeeded(snapshot) - if context.hasChanges { try context.save() } - return snapshot - } - - private func fetchSnapshot() throws -> VaultSnapshot { - let hosts = try context.fetch(FetchDescriptor()).map(\.value) - let groups = try context.fetch(FetchDescriptor()).map(\.value) - let identities = try context.fetch(FetchDescriptor()).map(\.value) - let keys = try context.fetch(FetchDescriptor()).map(\.value) - let knownHosts = try context.fetch(FetchDescriptor()).map(\.value) - let snippets = try context.fetch(FetchDescriptor()).map(\.value) - let snippetPackages = try context.fetch(FetchDescriptor()).map(\.value) - let portForwardings = try context.fetch(FetchDescriptor()).map(\.value) - return VaultSnapshot( - hosts: hosts, groups: groups, identities: identities, keys: keys, - knownHosts: knownHosts, snippets: snippets, - snippetPackages: snippetPackages, portForwardings: portForwardings - ) - } - - func save(_ snapshot: VaultSnapshot) throws -> VaultMutationSet { - do { - let oldSnapshot = try fetchSnapshot() - let journal = VaultSyncJournal(context: context) - _ = try journal.bootstrapIfNeeded(oldSnapshot) - let mutations = try journal.recordLocalChanges(from: oldSnapshot, to: snapshot) - try persist(snapshot) - try context.save() - return mutations - } catch { - context.rollback() - throw error - } - } - - func applyRemote(_ envelopes: [VaultEnvelope]) throws -> VaultRemoteApplyResult { - do { - let journal = VaultSyncJournal(context: context) - let decision = try journal.mergeDecision(for: envelopes) - var snapshot = try fetchSnapshot() - for envelope in decision.accepted { try snapshot.apply(envelope) } - try persist(snapshot) - try journal.acceptRemote(decision.accepted) - try journal.markNeedsUpload(decision.needsUpload) - try context.save() - return VaultRemoteApplyResult( - snapshot: snapshot, - acceptedKeys: Set(decision.accepted.map(\.key)), - needsUpload: decision.needsUpload - ) - } catch { - context.rollback() - throw error - } - } - - func recordExternalMutation() throws -> VaultMutationSet { - let snapshot = try fetchSnapshot() - let journal = VaultSyncJournal(context: context) - _ = try journal.bootstrapIfNeeded(snapshot) - return try journal.recordCurrentSnapshot(snapshot) - } - - private func persist(_ snapshot: VaultSnapshot) throws { - try reconcile( - try context.fetch(FetchDescriptor()), with: snapshot.hosts, - rowID: \.id, valueID: \.id, - make: PersistentHost.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), with: snapshot.groups, - rowID: \.id, valueID: \.id, - make: PersistentHostGroup.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), with: snapshot.identities, - rowID: \.id, valueID: \.id, - make: PersistentIdentity.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), with: snapshot.keys, - rowID: \.id, valueID: \.id, - make: PersistentSSHKey.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), with: snapshot.knownHosts, - rowID: \.id, valueID: \.id, - make: PersistentKnownHost.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), with: snapshot.snippets, - rowID: \.id, valueID: \.id, - make: PersistentSnippet.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), - with: snapshot.snippetPackages, rowID: \.id, valueID: \.id, - make: PersistentSnippetPackage.init, apply: { $0.apply($1) } - ) - try reconcile( - try context.fetch(FetchDescriptor()), - with: snapshot.portForwardings, rowID: \.id, valueID: \.id, - make: PersistentPortForwardingRule.init, apply: { $0.apply($1) } - ) - } - - private func reconcile( - _ rows: [Model], with values: [Value], - rowID: (Model) -> UUID, valueID: (Value) -> UUID, - make: (Value) -> Model, apply: (Model, Value) -> Void - ) throws { - var byID: [UUID: Model] = [:] - for row in rows { - let id = rowID(row) - if byID[id] == nil { byID[id] = row } else { context.delete(row) } - } - let wanted = Set(values.map(valueID)) - for row in byID.values where !wanted.contains(rowID(row)) { context.delete(row) } - for value in values { - if let row = byID[valueID(value)] { apply(row, value) } else { context.insert(make(value)) } - } - } -} - -extension HostPersistence: VaultPersisting {} +typealias HostPersistence = SharedHostPersistence diff --git a/TFM/Infrastructure/Database/SwiftDataHostRepository.swift b/TFM/Infrastructure/Database/SwiftDataHostRepository.swift index e26fcb36..64c68bca 100644 --- a/TFM/Infrastructure/Database/SwiftDataHostRepository.swift +++ b/TFM/Infrastructure/Database/SwiftDataHostRepository.swift @@ -1,222 +1,3 @@ -import Foundation -import SwiftData -import TermindApplication -import TermindDomain import TermindPersistence -import struct TermindDomain.Host - -@MainActor -final class SwiftDataHostRepository: HostRepository { - private let context: ModelContext - private var continuations: [UUID: AsyncStream>.Continuation] = [:] - var didRecordVaultChanges: ((VaultMutationSet) -> Void)? - - init(context: ModelContext) { - self.context = context - } - - func snapshot() throws -> RepositoryChange { - let hidden = Set( - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.entityType == "host" } - ) - ).map(\.entityID) - ) - let hosts = try context.fetch(FetchDescriptor()) - .map(\.value) - .filter { !hidden.contains($0.id) } - let groups = try context.fetch(FetchDescriptor()).map(\.value) - return RepositoryChange( - revision: try currentRevision(), - value: HostLibrarySnapshot(hosts: hosts, groups: groups), - reason: .initial - ) - } - - func changes() -> AsyncStream> { - let id = UUID() - return AsyncStream { continuation in - continuations[id] = continuation - if let initial = try? snapshot() { continuation.yield(initial) } - continuation.onTermination = { [weak self] _ in - Task { @MainActor in self?.continuations[id] = nil } - } - } - } - - func refresh() throws { - try publishMutation(reason: .refresh) - } - - func upsertHost(_ host: Host) throws { - let hostID = host.id - var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == hostID }) - descriptor.fetchLimit = 1 - if let existing = try context.fetch(descriptor).first { - existing.apply(host) - } else { - context.insert(PersistentHost(host)) - } - try commitMutation() - } - - func deletionImpact(for hostID: Host.ID) throws -> HostDeletionImpact { - let hosts = try context.fetch(FetchDescriptor()) - let snippets = try context.fetch(FetchDescriptor()) - let rules = try forwardingRules(for: hostID) - return HostDeletionImpact( - jumpHostReferences: hosts.filter { $0.jumpHostID == hostID }.count, - snippetReferences: snippets.filter { $0.targetHostIDs.contains(hostID) }.count, - forwardingRules: rules.count - ) - } - - /// The deletion marker, all durable side effects, and the revision become - /// visible in one database commit. A crash can therefore see either the - /// live host or a complete deletion saga, never half of each. - func requestHostDeletion(_ hostID: Host.ID) throws { - guard try persistentHost(hostID) != nil else { return } - if try pendingDeletion(hostID) != nil { return } - - let ruleIDs = try forwardingRules(for: hostID).map(\.id) - let payload = try JSONEncoder().encode( - HostDeletionPayload(hostID: hostID, forwardingRuleIDs: ruleIDs) - ) - let eventID = UUID() - let now = Date() - context.insert( - PersistentPendingDeletion( - entityType: "host", entityID: hostID, createdAt: now - ) - ) - for handlerID in HostDeletionOutbox.handlerIDs { - context.insert( - PersistentOutboxJob( - OutboxJob( - eventID: eventID, handlerID: handlerID, - eventType: HostDeletionOutbox.eventType, payload: payload, - createdAt: now, nextAttemptAt: now - ) - ) - ) - } - try commitMutation() - } - - func clearReferencesForDeletedHost(_ hostID: Host.ID) throws { - for host in try context.fetch(FetchDescriptor()) - where host.jumpHostID == hostID { - host.jumpHostID = nil - } - for snippet in try context.fetch(FetchDescriptor()) - where snippet.targetHostIDs.contains(hostID) { - snippet.targetHostIDs.removeAll { $0 == hostID } - } - for rule in try forwardingRules(for: hostID) { context.delete(rule) } - try context.delete( - model: PersistentCommandHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - try context.delete( - model: PersistentSFTPHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - try context.delete( - model: PersistentConnectionHistoryEntry.self, - where: #Predicate { $0.hostID == hostID } - ) - try commitMutation() - } - - func finalizeHostDeletion(_ hostID: Host.ID) throws { - if let host = try persistentHost(hostID) { context.delete(host) } - if let marker = try pendingDeletion(hostID) { context.delete(marker) } - try commitMutation() - } - - private func publishMutation(reason: RepositoryChangeReason) throws { - let revision = try advanceRevision() - let current = try snapshot() - let change = RepositoryChange( - revision: revision, value: current.value, reason: reason - ) - for continuation in continuations.values { continuation.yield(change) } - } - - private func commitMutation() throws { - do { - let revision = try advanceRevisionWithoutSaving() - let vaultMutations = try HostPersistence(context: context).recordExternalMutation() - try context.save() - if !vaultMutations.isEmpty { didRecordVaultChanges?(vaultMutations) } - try publishCommittedMutation(revision: revision) - } catch { - context.rollback() - throw error - } - } - - private func publishCommittedMutation(revision: Int64) throws { - let current = try snapshot() - let change = RepositoryChange( - revision: revision, value: current.value, reason: .mutation - ) - for continuation in continuations.values { continuation.yield(change) } - } - - private func persistentHost(_ id: Host.ID) throws -> PersistentHost? { - var descriptor = FetchDescriptor(predicate: #Predicate { $0.id == id }) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first - } - - private func pendingDeletion(_ hostID: Host.ID) throws -> PersistentPendingDeletion? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.entityType == "host" && $0.entityID == hostID } - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first - } - - private func forwardingRules( - for hostID: Host.ID - ) throws -> [PersistentPortForwardingRule] { - try context.fetch( - FetchDescriptor( - predicate: #Predicate { $0.hostID == hostID } - ) - ) - } - - private func currentRevision() throws -> Int64 { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.key == "repository" } - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first?.revision ?? 0 - } - - private func advanceRevision() throws -> Int64 { - let revision = try advanceRevisionWithoutSaving() - try context.save() - return revision - } - - private func advanceRevisionWithoutSaving() throws -> Int64 { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.key == "repository" } - ) - descriptor.fetchLimit = 1 - let metadata: PersistentDatabaseMetadata - if let existing = try context.fetch(descriptor).first { - metadata = existing - } else { - metadata = PersistentDatabaseMetadata() - context.insert(metadata) - } - metadata.revision &+= 1 - return metadata.revision - } -} +typealias SwiftDataHostRepository = SharedSwiftDataHostRepository diff --git a/TFM/Infrastructure/Database/SwiftDataOutboxStore.swift b/TFM/Infrastructure/Database/SwiftDataOutboxStore.swift index 8574f86c..7a2d8a60 100644 --- a/TFM/Infrastructure/Database/SwiftDataOutboxStore.swift +++ b/TFM/Infrastructure/Database/SwiftDataOutboxStore.swift @@ -1,82 +1,3 @@ -import Foundation -import SwiftData -import TermindDomain import TermindPersistence -@MainActor -final class SwiftDataOutboxStore: OutboxStore { - private let context: ModelContext - - init(context: ModelContext) { - self.context = context - } - - func enqueue(_ jobs: [OutboxJob]) throws { - for job in jobs { context.insert(PersistentOutboxJob(job)) } - try save() - } - - func nextReady(at date: Date) throws -> OutboxJob? { - let pending = OutboxJobStatus.pending.rawValue - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.status == pending && $0.nextAttemptAt <= date }, - sortBy: [SortDescriptor(\.createdAt), SortDescriptor(\.handlerID)] - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first?.value - } - - func markRunning(_ id: OutboxJob.ID) throws { - guard let job = try persistentJob(id) else { return } - job.status = OutboxJobStatus.running.rawValue - job.attemptCount += 1 - job.lastError = nil - try save() - } - - func markSucceeded(_ id: OutboxJob.ID) throws { - guard let job = try persistentJob(id) else { return } - job.status = OutboxJobStatus.succeeded.rawValue - job.lastError = nil - try save() - } - - func markFailed(_ id: OutboxJob.ID, error: String, retryAt: Date?) throws { - guard let job = try persistentJob(id) else { return } - job.status = (retryAt == nil ? OutboxJobStatus.failed : .pending).rawValue - job.lastError = error - if let retryAt { job.nextAttemptAt = retryAt } - try save() - } - - func resetInterruptedJobs() throws { - let running = OutboxJobStatus.running.rawValue - let jobs = try context.fetch( - FetchDescriptor(predicate: #Predicate { $0.status == running }) - ) - for job in jobs { job.status = OutboxJobStatus.pending.rawValue } - if !jobs.isEmpty { try save() } - } - - func retryFailedJob(_ id: OutboxJob.ID) throws { - guard let job = try persistentJob(id), - job.status == OutboxJobStatus.failed.rawValue - else { return } - job.status = OutboxJobStatus.pending.rawValue - job.nextAttemptAt = Date() - job.lastError = nil - try save() - } - - private func persistentJob(_ id: UUID) throws -> PersistentOutboxJob? { - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.id == id } - ) - descriptor.fetchLimit = 1 - return try context.fetch(descriptor).first - } - - private func save() throws { - try context.savePendingChanges() - } -} +typealias SwiftDataOutboxStore = SharedOutboxStore diff --git a/TFM/Infrastructure/Metrics/SSHMetricsSampler.swift b/TFM/Infrastructure/Metrics/SSHMetricsSampler.swift index d7023fc3..7120316e 100644 --- a/TFM/Infrastructure/Metrics/SSHMetricsSampler.swift +++ b/TFM/Infrastructure/Metrics/SSHMetricsSampler.swift @@ -1,124 +1,4 @@ -import Foundation -import TermindDomain -import TermindInfrastructure +import TermindRemoteInfrastructure -import struct TermindDomain.Host - -actor SSHMetricsSampler: MetricsSampling { - private let session: any SSHExecSessionProtocol - - init(session: any SSHExecSessionProtocol) { self.session = session } - func connect(_ configuration: SSHConnectionConfiguration) async throws { - try await session.connect(using: configuration) - } - func disconnect() async { await session.disconnect() } - - func sample() async throws -> MetricsSample { - let result = try await session.execute( - Self.command, workingDirectory: nil, stdin: nil, timeout: 8, - outputLimit: 262_144, onOutput: nil - ) - guard result.exitCode != 42 else { throw MetricsReadError.unsupported } - guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } - let sample = LinuxMetricsParser.parse(result.stdout) - guard sample.uptime != nil else { throw MetricsReadError.output } - return sample - } - - func filesystems() async throws -> [MetricsFilesystem]? { - let result = try await session.execute( - "LC_ALL=C; export LC_ALL; cat /proc/self/mountinfo; printf '__TERMIND_DF__\\n'; df -PTkl 2>/dev/null || df -PTk", - workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 524_288, onOutput: nil - ) - guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } - return try LinuxFilesystemParser.parse(result.stdout) - } - - func processes() async throws -> MetricsProcessSample? { - let result = try await session.execute( - Self.processCommand, workingDirectory: nil, stdin: nil, timeout: 5, - outputLimit: 1_048_576, onOutput: nil - ) - guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } - return try LinuxProcessParser.parse(result.stdout) - } - - private static let processCommand = """ - export LC_ALL=C - cat /proc/stat || exit 1 - printf 'pagesize '; getconf PAGESIZE 2>/dev/null || printf '\n' - printf '__TERMIND_PROCESSES__\n' - for f in /proc/[0-9]*/stat; do - if IFS= read -r line < "$f" 2>/dev/null; then printf '%s\n' "$line"; fi - done - exit 0 - """ - - func gpu() async throws -> NvidiaSnapshot? { - let result = try await session.execute( - "command -v nvidia-smi >/dev/null 2>&1 || exit 42; LC_ALL=C nvidia-smi -q -x", - workingDirectory: nil, stdin: nil, timeout: 5, - outputLimit: NvidiaMetricsParser.maximumBytes, onOutput: nil - ) - // NVIDIA exit code 6 means no device was found; other errors remain errors. - if result.exitCode == 42 || result.exitCode == 6 { return nil } - guard result.exitCode == 0, !result.stdoutTruncated else { throw MetricsReadError.output } - return try NvidiaMetricsParser.parse(Data(result.stdout.utf8)) - } - - private static let command = """ - export LC_ALL=C - [ -r /proc/uptime ] && [ -r /proc/stat ] || exit 42 - printf '__TERMIND_UPTIME__\\n'; cat /proc/uptime - printf '__TERMIND_CPU__\\n'; cat /proc/stat - printf '__TERMIND_LOAD__\\n'; cat /proc/loadavg 2>/dev/null - printf '__TERMIND_MEMORY__\\n'; cat /proc/meminfo 2>/dev/null - printf '__TERMIND_DISKS__\\n' - while read -r major minor name stats; do - case "$name" in loop*|ram*|zram*) continue;; esac - [ -d "/sys/block/$name" ] || continue - set -- /sys/block/"$name"/slaves/* - [ -e "$1" ] && continue - printf '%s %s\\n' "$name" "$stats" - done < /proc/diskstats - printf '__TERMIND_BLOCKS__\\n' - while read -r major minor name stats; do - printf '%s:%s %s %s\\n' "$major" "$minor" "$name" "$stats" - done < /proc/diskstats - printf '__TERMIND_NETWORK__\\n'; cat /proc/net/dev 2>/dev/null - printf '__TERMIND_INTERFACES__\\n' - for i in /sys/class/net/*; do - [ -e "$i" ] || continue - kind=virtual; [ -e "$i/device" ] && kind=hardware - printf '%s %s\\n' "${i##*/}" "$kind" - done - printf '__TERMIND_ADDRESSES__\\n'; ip -o addr show 2>/dev/null - printf '__TERMIND_TCP__\\n'; cat /proc/net/snmp 2>/dev/null - exit 0 - """ -} - -final class MetricsPreferences: MetricsConfigurationStoring { - private let defaults: UserDefaults - init(defaults: UserDefaults = .standard) { self.defaults = defaults } - func load() -> [MetricsSelection] { - guard let data = defaults.data(forKey: "metrics.servers") else { return [] } - return (try? JSONDecoder().decode([MetricsSelection].self, from: data)) ?? [] - } - func save(_ selections: [MetricsSelection]) { - guard let data = try? JSONEncoder().encode(selections) else { return } - defaults.set(data, forKey: "metrics.servers") - } -} - -nonisolated extension RemoteConnectionCoordinator { - func makeMetricsSampler() -> any MetricsSampling { SSHMetricsSampler(session: makeExecSession()) } - - func discoverMetricsHostKey( - host: Host, proxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration] - ) async throws -> SSHHostKeyObservation { - try await SSHSessionActor.discoverHostKey( - hostID: host.id, host: host.host, port: host.port, proxy: proxy, jumps: jumps - ) - } -} +typealias SSHMetricsSampler = TermindRemoteInfrastructure.SSHMetricsSampler +typealias MetricsPreferences = TermindRemoteInfrastructure.MetricsPreferences diff --git a/TFM/Infrastructure/SSH/PortForwardingController.swift b/TFM/Infrastructure/SSH/PortForwardingController.swift index 4761a403..06e33c76 100644 --- a/TFM/Infrastructure/SSH/PortForwardingController.swift +++ b/TFM/Infrastructure/SSH/PortForwardingController.swift @@ -1,258 +1,4 @@ -import Foundation -import TermindDomain +import TermindRemoteInfrastructure -import struct TermindDomain.Host - -nonisolated enum PortForwardingRuntimeState: Hashable, Sendable { - case stopped - case preparing - case awaitingTrust - case awaitingAuthentication - case connecting - case starting - case running(activeConnections: Int) - case reconnecting(attempt: Int, nextRetry: Date, message: String) - case failed(String) - - var isActive: Bool { - switch self { - case .preparing, .awaitingTrust, .awaitingAuthentication, .connecting, - .starting, .running, .reconnecting: - true - case .stopped, .failed: false - } - } - - var label: String { - switch self { - case .stopped: String(localized: "Stopped") - case .preparing: String(localized: "Preparing") - case .awaitingTrust: String(localized: "Awaiting trust") - case .awaitingAuthentication: String(localized: "Awaiting authentication") - case .connecting: String(localized: "Connecting") - case .starting: String(localized: "Starting") - case .running(let count): - count == 0 - ? String(localized: "Running") - : count == 1 - ? String(localized: "Running · 1 connection") - : String(localized: "Running · \(count) connections") - case .reconnecting(let attempt, _, _): String(localized: "Reconnecting · attempt \(attempt)") - case .failed(let message): message - } - } -} - -/// Process-local intent and live resources. No state in this type is persisted, -/// which guarantees that launching Termind never silently exposes a saved port. -@Observable -final class PortForwardingController { - private let hosts: HostLibraryModel - private(set) var states: [PortForwardingRule.ID: PortForwardingRuntimeState] = [:] - private var desired: Set = [] - private var sessions: [Host.ID: SSHForwardingSession] = [:] - private var connectionTasks: [Host.ID: Task] = [:] - private var reconnectTasks: [Host.ID: Task] = [:] - private var reconnectAttempts: [Host.ID: Int] = [:] - private var connectedAt: [Host.ID: Date] = [:] - - init(hosts: HostLibraryModel) { self.hosts = hosts } - - func state(for id: PortForwardingRule.ID) -> PortForwardingRuntimeState { - states[id] ?? .stopped - } - - func isEnabled(_ id: PortForwardingRule.ID) -> Bool { desired.contains(id) } - - func start(_ id: PortForwardingRule.ID) { - guard let rule = hosts.portForwarding(id), !desired.contains(id) else { return } - desired.insert(id) - states[id] = .preparing - Task { await start(rule) } - } - - func start(_ ids: [PortForwardingRule.ID]) { ids.forEach(start) } - - func stop(_ id: PortForwardingRule.ID) { - desired.remove(id) - states[id] = .stopped - guard let rule = hosts.portForwarding(id), let session = sessions[rule.hostID] else { return } - Task { - await session.stop(id) - await disconnectHostIfIdle(rule.hostID) - } - } - - func stop(_ ids: [PortForwardingRule.ID]) { ids.forEach(stop) } - - func stopAll() { - let all = Array(desired) - desired.removeAll() - for id in all { states[id] = .stopped } - let active = sessions - sessions.removeAll() - for task in connectionTasks.values { task.cancel() } - connectionTasks.removeAll() - for task in reconnectTasks.values { task.cancel() } - reconnectTasks.removeAll() - reconnectAttempts.removeAll() - connectedAt.removeAll() - Task { for session in active.values { await session.disconnect() } } - } - - private func start(_ rule: PortForwardingRule) async { - do { - guard rule.isValid else { - throw TermindSSHError.route(String(localized: "The forwarding rule is incomplete.")) - } - guard let host = hosts.host(rule.hostID) else { - throw TermindSSHError.route(String(localized: "The forwarding host is unavailable.")) - } - let session: SSHForwardingSession - if let existing = sessions[host.id] { - session = existing - } else { - let created = makeSession(for: host.id) - sessions[host.id] = created - session = created - states[rule.id] = .connecting - let task = Task { - let configuration = try hosts.sshConnectionConfiguration(for: host) - try await created.connect(configuration) - } - connectionTasks[host.id] = task - do { try await task.value } catch { - sessions[host.id] = nil - connectionTasks[host.id] = nil - throw error - } - connectionTasks[host.id] = nil - connectedAt[host.id] = Date() - } - if let task = connectionTasks[host.id] { try await task.value } - guard desired.contains(rule.id) else { return } - states[rule.id] = .starting - try await session.start(rule) - guard desired.contains(rule.id) else { - await session.stop(rule.id) - return - } - states[rule.id] = .running(activeConnections: 0) - } catch { - if desired.contains(rule.id), - let sshError = error as? TermindSSHError, - sshError.isRetryableForwardingFailure - { - if let session = sessions.removeValue(forKey: rule.hostID) { - await session.disconnect() - } - if reconnectTasks[rule.hostID] == nil { - scheduleReconnect( - rule.hostID, attempt: 1, message: error.localizedDescription - ) - } - } else { - desired.remove(rule.id) - states[rule.id] = .failed(error.localizedDescription) - await disconnectHostIfIdle(rule.hostID) - } - } - } - - private func disconnectHostIfIdle(_ hostID: Host.ID) async { - let hasActiveRule = desired.contains { hosts.portForwarding($0)?.hostID == hostID } - guard !hasActiveRule else { return } - let session = sessions.removeValue(forKey: hostID) - connectionTasks[hostID]?.cancel() - connectionTasks[hostID] = nil - reconnectTasks[hostID]?.cancel() - reconnectTasks[hostID] = nil - reconnectAttempts[hostID] = nil - connectedAt[hostID] = nil - if let session { await session.disconnect() } - } - - private func makeSession(for hostID: Host.ID) -> SSHForwardingSession { - SSHForwardingSession( - connectionHandler: { [weak self] id, count in - Task { @MainActor in - guard let self, self.desired.contains(id) else { return } - self.states[id] = .running(activeConnections: count) - } - }, - failureHandler: { [weak self] message in - Task { @MainActor in self?.handleConnectionFailure(hostID, message: message) } - } - ) - } - - private func handleConnectionFailure(_ hostID: Host.ID, message: String) { - guard reconnectTasks[hostID] == nil else { return } - let ids = desiredRuleIDs(for: hostID) - guard !ids.isEmpty else { return } - sessions[hostID] = nil - connectionTasks[hostID]?.cancel() - connectionTasks[hostID] = nil - let wasStable = connectedAt[hostID].map { Date().timeIntervalSince($0) >= 60 } == true - let attempt = wasStable ? 1 : (reconnectAttempts[hostID] ?? 0) + 1 - scheduleReconnect(hostID, attempt: attempt, message: message) - } - - private func scheduleReconnect(_ hostID: Host.ID, attempt: Int, message: String) { - let delays: [TimeInterval] = [1, 2, 5, 10, 30] - let delay = delays[min(attempt - 1, delays.count - 1)] - let nextRetry = Date().addingTimeInterval(delay) - reconnectAttempts[hostID] = attempt - for id in desiredRuleIDs(for: hostID) { - states[id] = .reconnecting(attempt: attempt, nextRetry: nextRetry, message: message) - } - reconnectTasks[hostID] = Task { [weak self] in - do { try await Task.sleep(for: .seconds(delay)) } catch { return } - guard let self else { return } - await self.reconnect(hostID, attempt: attempt) - } - } - - private func reconnect(_ hostID: Host.ID, attempt: Int) async { - reconnectTasks[hostID] = nil - let ids = desiredRuleIDs(for: hostID) - guard !ids.isEmpty, let host = hosts.host(hostID) else { return } - let session = makeSession(for: hostID) - do { - let configuration = try hosts.sshConnectionConfiguration(for: host) - try await session.connect(configuration) - guard !desiredRuleIDs(for: hostID).isEmpty else { - await session.disconnect() - return - } - sessions[hostID] = session - connectedAt[hostID] = Date() - for id in desiredRuleIDs(for: hostID) { - guard let rule = hosts.portForwarding(id) else { continue } - states[id] = .starting - try await session.start(rule) - states[id] = .running(activeConnections: 0) - } - } catch { - sessions[hostID] = nil - await session.disconnect() - if let sshError = error as? TermindSSHError, - !sshError.isRetryableForwardingFailure - { - for id in desiredRuleIDs(for: hostID) { - desired.remove(id) - states[id] = .failed(error.localizedDescription) - } - reconnectAttempts[hostID] = nil - } else { - scheduleReconnect( - hostID, attempt: attempt + 1, message: error.localizedDescription - ) - } - } - } - - private func desiredRuleIDs(for hostID: Host.ID) -> [PortForwardingRule.ID] { - desired.filter { hosts.portForwarding($0)?.hostID == hostID } - } -} +typealias PortForwardingRuntimeState = TermindRemoteInfrastructure.PortForwardingRuntimeState +typealias PortForwardingController = TermindRemoteInfrastructure.PortForwardingController diff --git a/TFM/Infrastructure/SSH/RemoteConnectionCoordinator.swift b/TFM/Infrastructure/SSH/RemoteConnectionCoordinator.swift index 0dc0977e..cfd26166 100644 --- a/TFM/Infrastructure/SSH/RemoteConnectionCoordinator.swift +++ b/TFM/Infrastructure/SSH/RemoteConnectionCoordinator.swift @@ -1,62 +1,4 @@ -import Foundation +import TermindInfrastructure +import TermindRemoteInfrastructure -/// Application-owned construction and one-shot operation boundary for all -/// SSH/SFTP transports. Features receive this service instead of constructing -/// infrastructure clients in views. -nonisolated final class RemoteConnectionCoordinator: Sendable { - func makeSSHSession( - output: @escaping SSHSessionActor.OutputHandler, - exit: @escaping SSHSessionActor.ExitHandler, - shellIntegration: @escaping SSHSessionActor.ShellIntegrationHandler = { _ in } - ) -> SSHSessionActor { - SSHSessionActor( - output: output, exit: exit, shellIntegration: shellIntegration - ) - } - - func makeSFTPClient() -> any SFTPClientProtocol { SFTPClient() } - - func makeExecSession() -> any SSHExecSessionProtocol { SSHExecSession() } - - /// Mirrors `connectSFTP`, including its guarantee that a session which fails - /// to come up is torn down here rather than handed back half-open. - func connectExec( - using configuration: SSHConnectionConfiguration - ) async throws -> any SSHExecSessionProtocol { - let session = makeExecSession() - do { - try await session.connect(using: configuration) - return session - } catch { - await session.disconnect() - throw error - } - } - - func connectSFTP( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler? = nil, - connectionFailure: (@Sendable (String) -> Void)? = nil - ) async throws -> any SFTPClientProtocol { - let client = makeSFTPClient() - do { - try await client.connect( - using: configuration, progress: progress, - connectionFailure: connectionFailure - ) - return client - } catch { - await client.disconnect() - throw error - } - } - - func installAuthorizedKey( - _ publicKey: String, named keyName: String, - using configuration: SSHConnectionConfiguration - ) async throws { - try await SSHSessionActor.installAuthorizedKey( - publicKey, named: keyName, using: configuration - ) - } -} +typealias RemoteConnectionCoordinator = TermindRemoteInfrastructure.RemoteConnectionCoordinator diff --git a/TFM/Infrastructure/SSH/SFTPClient.swift b/TFM/Infrastructure/SSH/SFTPClient.swift index 3483413b..982254c4 100644 --- a/TFM/Infrastructure/SSH/SFTPClient.swift +++ b/TFM/Infrastructure/SSH/SFTPClient.swift @@ -1,980 +1,9 @@ -import CSSH2 -import Darwin -import Foundation - -nonisolated struct RemoteFile: Identifiable, Hashable, Sendable { - nonisolated enum Kind: String, Hashable, Sendable { - case file, directory, symbolicLink, special, unknown - - var symbol: String { - switch self { - case .directory: "folder.fill" - case .symbolicLink: "link" - case .file: "doc" - case .special, .unknown: "questionmark.square.dashed" - } - } - } - - var path: String - var name: String - var kind: Kind - var size: Int64 - var modifiedAt: Date? - var permissions: UInt32 - var ownerUID: UInt32 - var ownerGID: UInt32 - - var id: String { path } - var isDirectory: Bool { kind == .directory } - var isHidden: Bool { name.hasPrefix(".") } - - var permissionMode: UInt32 { permissions & 0o7777 } - - var sftpKindDescription: String { - switch kind { - case .directory: return String(localized: "Folder") - case .symbolicLink: return String(localized: "Symbolic Link") - case .special: return String(localized: "Special") - case .unknown: return String(localized: "Unknown") - case .file: - let fileExtension = (name as NSString).pathExtension - return fileExtension.isEmpty ? String(localized: "File") : fileExtension.lowercased() - } - } - - var octalPermissionDescription: String { - String(format: "%04o", permissionMode) - } - - var permissionDescription: String { - let type: Character = - switch kind { - case .file: "-" - case .directory: "d" - case .symbolicLink: "l" - case .special: "s" - case .unknown: "?" - } - let masks: [UInt32] = [0o400, 0o200, 0o100, 0o040, 0o020, 0o010, 0o004, 0o002, 0o001] - let letters: [Character] = ["r", "w", "x", "r", "w", "x", "r", "w", "x"] - return String([type] + zip(masks, letters).map { permissions & $0.0 == 0 ? "-" : $0.1 }) - } -} - -nonisolated final class TransferCancellationToken: @unchecked Sendable { - private let lock = NSLock() - private var cancelled = false - - var isCancelled: Bool { lock.withLock { cancelled } } - func cancel() { lock.withLock { cancelled = true } } -} - -nonisolated struct SFTPProgress: Sendable { - var completedBytes: Int64 - var totalBytes: Int64 - var bytesPerSecond: Int64 -} - -nonisolated private struct SFTPProgressMeter { - let total: Int64 - let report: @Sendable (SFTPProgress) -> Void - private(set) var completed: Int64 = 0 - private var sampledBytes: Int64 = 0 - private var sampledAt = ContinuousClock.now - - nonisolated init( - total: Int64, report: @escaping @Sendable (SFTPProgress) -> Void - ) { - self.total = total - self.report = report - } - - nonisolated mutating func add(_ bytes: Int) { - completed += Int64(bytes) - sampledBytes += Int64(bytes) - let elapsed = sampledAt.duration(to: .now) - guard elapsed >= .milliseconds(250) else { return } - let components = elapsed.components - let seconds = max( - 0.001, - Double(components.seconds) + Double(components.attoseconds) / 1e18 - ) - report( - SFTPProgress( - completedBytes: completed, totalBytes: total, - bytesPerSecond: Int64(Double(sampledBytes) / seconds) - )) - sampledBytes = 0 - sampledAt = .now - } - - nonisolated func finish() { - report( - SFTPProgress( - completedBytes: completed, totalBytes: total, bytesPerSecond: 0 - )) - } -} - -nonisolated protocol SFTPClientProtocol: Sendable { - func connect( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler? - ) async throws - func connect( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler?, - connectionFailure: (@Sendable (String) -> Void)? - ) async throws - func disconnect() async - func homeDirectory() async throws -> String - func listDirectory(at path: String) async throws -> [RemoteFile] - func attributes(at path: String) async throws -> RemoteFile? - func readFile(at path: String, maximumBytes: Int) async throws -> Data - func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws - func setPermissions(at path: String, to permissions: UInt32) async throws - func createDirectory(at path: String) async throws - func rename(from source: String, to destination: String, overwrite: Bool) async throws - func removeFile(at path: String) async throws - func removeDirectory(at path: String) async throws - func uploadFile( - from localURL: URL, to remotePath: String, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws - func downloadFile( - from remotePath: String, to localURL: URL, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws - func copyFile( - from sourcePath: String, - to destinationPath: String, - on destination: any SFTPClientProtocol, - byteCount: Int64, - overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws -} - -nonisolated extension SFTPClientProtocol { - func connect( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler?, - connectionFailure: (@Sendable (String) -> Void)? - ) async throws { - try await connect(using: configuration, progress: progress) - } - - func readFile(at path: String, maximumBytes: Int) async throws -> Data { - throw SFTPError.operation("This SFTP client does not support inline reads.") - } - - func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { - throw SFTPError.operation("This SFTP client does not support inline writes.") - } - - func copyFile( - from sourcePath: String, - to destinationPath: String, - on destination: any SFTPClientProtocol, - byteCount: Int64, - overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - throw SFTPError.operation("This SFTP client does not support streamed remote copying.") - } -} - -nonisolated enum SFTPError: LocalizedError, Sendable { - case notConnected - case connectionLost(String) - case cancelled - case localFile(String) - case operation(String) - - var errorDescription: String? { - switch self { - case .notConnected: String(localized: "The SFTP connection is not available.") - case .connectionLost(let message): message - case .cancelled: String(localized: "Transfer cancelled.") - case .localFile(let message), .operation(let message): message - } - } -} - -actor SFTPClient: SFTPClientProtocol { - private nonisolated let worker = SFTPWorker() - - func connect( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler? = nil - ) async throws { - try await worker.connect(using: configuration, progress: progress) - } - - func connect( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler?, - connectionFailure: (@Sendable (String) -> Void)? - ) async throws { - try await worker.connect( - using: configuration, progress: progress, - connectionFailure: connectionFailure - ) - } - - func disconnect() async { await worker.disconnect() } - - func homeDirectory() async throws -> String { - try await worker.homeDirectory() - } - - func listDirectory(at path: String) async throws -> [RemoteFile] { - try await worker.listDirectory(at: path) - } - - func attributes(at path: String) async throws -> RemoteFile? { - try await worker.attributes(at: path) - } - - func readFile(at path: String, maximumBytes: Int) async throws -> Data { - try await worker.readFile(at: path, maximumBytes: maximumBytes) - } - - func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { - try await worker.writeFile(data, at: path, overwrite: overwrite) - } - - func setPermissions(at path: String, to permissions: UInt32) async throws { - try await worker.setPermissions(at: path, to: permissions) - } - - func createDirectory(at path: String) async throws { - try await worker.createDirectory(at: path) - } - - func rename(from source: String, to destination: String, overwrite: Bool) async throws { - try await worker.rename(from: source, to: destination, overwrite: overwrite) - } - - func removeFile(at path: String) async throws { try await worker.removeFile(at: path) } - func removeDirectory(at path: String) async throws { try await worker.removeDirectory(at: path) } - - func uploadFile( - from localURL: URL, to remotePath: String, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - try await worker.uploadFile( - from: localURL, to: remotePath, overwrite: overwrite, - cancellation: cancellation, progress: progress - ) - } - - func downloadFile( - from remotePath: String, to localURL: URL, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - try await worker.downloadFile( - from: remotePath, to: localURL, overwrite: overwrite, - cancellation: cancellation, progress: progress - ) - } - - func copyFile( - from sourcePath: String, - to destinationPath: String, - on destination: any SFTPClientProtocol, - byteCount: Int64, - overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - guard let destination = destination as? SFTPClient else { - throw SFTPError.operation("The destination cannot receive a streamed SFTP copy.") - } - let relay = try SFTPSocketRelay() - defer { relay.close() } - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - defer { relay.finishWriting() } - try await self.worker.downloadFile( - from: sourcePath, toSocket: relay.writer, - cancellation: cancellation - ) - } - group.addTask { - try await destination.worker.uploadFile( - fromSocket: relay.reader, to: destinationPath, - byteCount: byteCount, overwrite: overwrite, - cancellation: cancellation, progress: progress - ) - } - do { - while try await group.next() != nil {} - } catch { - cancellation.cancel() - relay.stop() - group.cancelAll() - throw error - } - } - } -} - -private nonisolated final class SFTPSocketRelay: @unchecked Sendable { - let writer: Int32 - let reader: Int32 - private let lock = NSLock() - private var closed = false - - init() throws { - var pair: [Int32] = [-1, -1] - guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else { - throw SFTPError.operation("Unable to create the SFTP copy relay: \(String(cString: strerror(errno)))") - } - writer = pair[0] - reader = pair[1] - var yes: Int32 = 1 - for descriptor in pair { - _ = setsockopt( - descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes, - socklen_t(MemoryLayout.size) - ) - } - } - - func finishWriting() { - lock.withLock { - guard !closed else { return } - _ = Darwin.shutdown(writer, SHUT_WR) - } - } - - func stop() { - lock.withLock { - guard !closed else { return } - _ = Darwin.shutdown(writer, SHUT_RDWR) - _ = Darwin.shutdown(reader, SHUT_RDWR) - } - } - - func close() { - lock.withLock { - guard !closed else { return } - closed = true - Darwin.close(writer) - Darwin.close(reader) - } - } -} - -private nonisolated final class SFTPWorker: @unchecked Sendable { - private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.sftp") - private var routeTransport: SSHRoutedSocket? - private var session: OpaquePointer? - private var sftp: OpaquePointer? - private var keepaliveTimer: (any DispatchSourceTimer)? - private var connectionFailure: (@Sendable (String) -> Void)? - private var didReportConnectionFailure = false - - deinit { cleanup() } - - func connect( - using configuration: SSHConnectionConfiguration, - progress: SSHRouteProgressHandler?, - connectionFailure: (@Sendable (String) -> Void)? = nil - ) async throws { - try await queue.perform { - self.cleanup() - let transport = try SSHRoutedSocket.open( - host: configuration.host, port: configuration.port, - proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20, - progress: progress - ) - progress?(.connecting(configuration.hostID)) - self.routeTransport = transport - guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library("Unable to create an SSH session.") - } - self.session = session - libssh2_session_set_blocking(session, 1) - guard libssh2_session_handshake(session, transport.socket) == 0 else { - throw SSHSessionWorker.sessionError(session, prefix: "SSH handshake failed") - } - let observed = try SSHSessionWorker.observation( - host: configuration.host, port: configuration.port, session: session - ) - try SSHSessionWorker.validateHostKey( - observed, expected: configuration.expectedHostKeys - ) - progress?(.authenticating(configuration.hostID)) - try SSHSessionWorker.authenticate( - username: configuration.username, - credentials: configuration.credentials, - session: session - ) - progress?(.authenticated(configuration.hostID)) - progress?(.initializing(configuration.hostID, String(localized: "Starting SFTP subsystem"))) - guard let sftp = libssh2_sftp_init(session) else { - throw SSHSessionWorker.sessionError(session, prefix: "Unable to start SFTP") - } - self.sftp = sftp - self.connectionFailure = connectionFailure - self.didReportConnectionFailure = false - self.startKeepalive(for: session) - } - } - - func disconnect() async { await queue.perform { self.cleanup() } } - - func homeDirectory() async throws -> String { - try await queue.perform { - let sftp = try self.requireSFTP() - let source = "." - var target = [CChar](repeating: 0, count: 4 * 1024) - let count = source.withCString { sourceBuffer in - libssh2_sftp_symlink_ex( - sftp, sourceBuffer, UInt32(source.utf8.count), - &target, UInt32(target.count), Int32(LIBSSH2_SFTP_REALPATH) - ) - } - guard count > 0 else { throw self.error("Unable to resolve the remote home directory") } - let bytes = target.prefix(Int(count)).map { UInt8(bitPattern: $0) } - return RemotePath.normalized(String(decoding: bytes, as: UTF8.self)) - } - } - - func listDirectory(at path: String) async throws -> [RemoteFile] { - try await queue.perform { - let sftp = try self.requireSFTP() - guard - let handle = path.withCString({ - libssh2_sftp_open_ex( - sftp, $0, UInt32(path.utf8.count), 0, 0, - Int32(LIBSSH2_SFTP_OPENDIR) - ) - }) - else { - throw self.error("Unable to open \(path)") - } - defer { _ = libssh2_sftp_close_handle(handle) } - var result: [RemoteFile] = [] - var name = [CChar](repeating: 0, count: 4 * 1024) - while true { - var attributes = LIBSSH2_SFTP_ATTRIBUTES() - let count = libssh2_sftp_readdir_ex( - handle, &name, name.count, nil, 0, &attributes - ) - if count > 0 { - let bytes = name.prefix(Int(count)).map { UInt8(bitPattern: $0) } - let filename = String(decoding: bytes, as: UTF8.self) - guard filename != ".", filename != ".." else { continue } - result.append( - Self.remoteFile(path: Self.join(path, filename), name: filename, attributes: attributes)) - } else if count == 0 { - break - } else { - throw self.error("Unable to read \(path)") - } - } - return result.sorted { - if $0.isDirectory != $1.isDirectory { return $0.isDirectory } - return $0.name.localizedStandardCompare($1.name) == .orderedAscending - } - } - } - - func attributes(at path: String) async throws -> RemoteFile? { - try await queue.perform { - let sftp = try self.requireSFTP() - var attributes = LIBSSH2_SFTP_ATTRIBUTES() - let result = path.withCString { - libssh2_sftp_stat_ex(sftp, $0, UInt32(path.utf8.count), Int32(LIBSSH2_SFTP_LSTAT), &attributes) - } - if result == 0 { - return Self.remoteFile( - path: path, name: URL(fileURLWithPath: path).lastPathComponent, - attributes: attributes - ) - } - if libssh2_sftp_last_error(sftp) == LIBSSH2_FX_NO_SUCH_FILE { return nil } - throw self.error("Unable to inspect \(path)") - } - } - - func readFile(at path: String, maximumBytes: Int) async throws -> Data { - try await queue.perform { - guard maximumBytes > 0 else { return Data() } - return try self.withRemoteReadHandle(at: path) { handle in - var data = Data() - var buffer = [CChar](repeating: 0, count: min(64 * 1024, maximumBytes + 1)) - while data.count <= maximumBytes { - let wanted = min(buffer.count, maximumBytes + 1 - data.count) - let count = libssh2_sftp_read(handle, &buffer, wanted) - if count > 0 { - data.append(Data(bytes: buffer, count: count)) - } else if count == 0 { - break - } else { - throw self.error("Unable to read \(path)") - } - } - guard data.count <= maximumBytes else { - throw SFTPError.operation("\(path) exceeds the inline read limit of \(maximumBytes) bytes.") - } - return data - } - } - } - - func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - if !overwrite { - var attributes = LIBSSH2_SFTP_ATTRIBUTES() - let exists = path.withCString { - libssh2_sftp_stat_ex( - sftp, $0, UInt32(path.utf8.count), - Int32(LIBSSH2_SFTP_LSTAT), &attributes - ) == 0 - } - if exists { throw SFTPError.operation("\(path) already exists.") } - } - try self.withAtomicRemoteWrite( - sftp: sftp, destination: path, overwrite: overwrite, - temporaryTag: ".termind-agent-part-" - ) { handle in - try data.withUnsafeBytes { bytes in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { - return - } - var offset = 0 - while offset < bytes.count { - let written = libssh2_sftp_write( - handle, base.advanced(by: offset), bytes.count - offset - ) - guard written > 0 else { throw self.error("Unable to write \(path)") } - offset += written - } - } - } - } - } - - func setPermissions(at path: String, to permissions: UInt32) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - var attributes = LIBSSH2_SFTP_ATTRIBUTES() - attributes.flags = UInt(LIBSSH2_SFTP_ATTR_PERMISSIONS) - attributes.permissions = UInt(permissions & 0o7777) - let result = path.withCString { - libssh2_sftp_stat_ex( - sftp, $0, UInt32(path.utf8.count), - Int32(LIBSSH2_SFTP_SETSTAT), &attributes - ) - } - guard result == 0 else { - throw self.error("Unable to change permissions for \(path)") - } - } - } - - func createDirectory(at path: String) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - let result = path.withCString { - libssh2_sftp_mkdir_ex(sftp, $0, UInt32(path.utf8.count), 0o755) - } - guard result == 0 else { throw self.error("Unable to create \(path)") } - } - } - - func rename(from source: String, to destination: String, overwrite: Bool) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 - let result = source.withCString { sourceBuffer in - destination.withCString { destinationBuffer in - libssh2_sftp_rename_ex( - sftp, sourceBuffer, UInt32(source.utf8.count), - destinationBuffer, UInt32(destination.utf8.count), flags - ) - } - } - guard result == 0 else { throw self.error("Unable to rename \(source)") } - } - } - - func removeFile(at path: String) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - guard - path.withCString({ - libssh2_sftp_unlink_ex(sftp, $0, UInt32(path.utf8.count)) - }) == 0 - else { - throw self.error("Unable to delete \(path)") - } - } - } - - func removeDirectory(at path: String) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - guard - path.withCString({ - libssh2_sftp_rmdir_ex(sftp, $0, UInt32(path.utf8.count)) - }) == 0 - else { - throw self.error("Unable to delete \(path)") - } - } - } - - func uploadFile( - from localURL: URL, to remotePath: String, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - let file: FileHandle - do { file = try FileHandle(forReadingFrom: localURL) } catch { - throw SFTPError.localFile(error.localizedDescription) - } - defer { try? file.close() } - try self.withAtomicRemoteWrite( - sftp: sftp, destination: remotePath, overwrite: overwrite - ) { handle in - let total = - (try? localURL.resourceValues(forKeys: [.fileSizeKey]).fileSize) - .map(Int64.init) ?? 0 - var meter = SFTPProgressMeter(total: total, report: progress) - while true { - if cancellation.isCancelled { throw SFTPError.cancelled } - let data = try file.read(upToCount: 64 * 1024) ?? Data() - if data.isEmpty { break } - try data.withUnsafeBytes { bytes in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) - else { return } - var offset = 0 - while offset < bytes.count { - if cancellation.isCancelled { throw SFTPError.cancelled } - let written = libssh2_sftp_write( - handle, base.advanced(by: offset), bytes.count - offset - ) - guard written > 0 else { - throw self.error("Unable to upload \(remotePath)") - } - offset += written - meter.add(written) - } - } - } - meter.finish() - } - } - } - - func downloadFile( - from remotePath: String, to localURL: URL, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - try await queue.perform { - try self.withRemoteReadHandle(at: remotePath) { handle in - let fm = FileManager.default - let temporary = localURL.deletingLastPathComponent().appendingPathComponent( - ".termind-part-\(UUID().uuidString)") - guard fm.createFile(atPath: temporary.path, contents: nil) else { - throw SFTPError.localFile("Unable to create \(temporary.path)") - } - var succeeded = false - defer { if !succeeded { try? fm.removeItem(at: temporary) } } - let file = try FileHandle(forWritingTo: temporary) - defer { try? file.close() } - var attrs = LIBSSH2_SFTP_ATTRIBUTES() - _ = libssh2_sftp_fstat_ex(handle, &attrs, 0) - let total = Int64(attrs.filesize) - var meter = SFTPProgressMeter(total: total, report: progress) - var buffer = [CChar](repeating: 0, count: 64 * 1024) - while true { - if cancellation.isCancelled { throw SFTPError.cancelled } - let count = libssh2_sftp_read(handle, &buffer, buffer.count) - if count > 0 { - try file.write(contentsOf: Data(bytes: buffer, count: count)) - meter.add(count) - } else if count == 0 { - break - } else { - throw self.error("Unable to download \(remotePath)") - } - } - try file.synchronize() - meter.finish() - if fm.fileExists(atPath: localURL.path) { - guard overwrite else { throw SFTPError.localFile("\(localURL.lastPathComponent) already exists.") } - _ = try fm.replaceItemAt(localURL, withItemAt: temporary) - } else { - try fm.moveItem(at: temporary, to: localURL) - } - succeeded = true - } - } - } - - func downloadFile( - from remotePath: String, toSocket socket: Int32, - cancellation: TransferCancellationToken - ) async throws { - try await queue.perform { - try self.withRemoteReadHandle(at: remotePath) { handle in - var buffer = [CChar](repeating: 0, count: 64 * 1024) - while true { - if cancellation.isCancelled { throw SFTPError.cancelled } - let count = libssh2_sftp_read(handle, &buffer, buffer.count) - if count > 0 { - var offset = 0 - while offset < count { - if cancellation.isCancelled { throw SFTPError.cancelled } - let sent = buffer.withUnsafeBytes { bytes in - Darwin.send( - socket, bytes.baseAddress!.advanced(by: offset), - count - offset, 0 - ) - } - if sent > 0 { - offset += sent - } else if sent < 0, errno == EINTR { - continue - } else { - throw SFTPError.operation("The SFTP copy relay was interrupted.") - } - } - } else if count == 0 { - break - } else { - throw self.error("Unable to read \(remotePath)") - } - } - } - } - } - - func uploadFile( - fromSocket socket: Int32, to remotePath: String, - byteCount: Int64, overwrite: Bool, - cancellation: TransferCancellationToken, - progress: @escaping @Sendable (SFTPProgress) -> Void - ) async throws { - try await queue.perform { - let sftp = try self.requireSFTP() - try self.withAtomicRemoteWrite( - sftp: sftp, destination: remotePath, overwrite: overwrite - ) { handle in - var meter = SFTPProgressMeter(total: byteCount, report: progress) - var buffer = [CChar](repeating: 0, count: 64 * 1024) - while true { - if cancellation.isCancelled { throw SFTPError.cancelled } - let count = Darwin.recv(socket, &buffer, buffer.count, 0) - if count > 0 { - try buffer.withUnsafeBytes { bytes in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) - else { return } - var offset = 0 - while offset < count { - if cancellation.isCancelled { throw SFTPError.cancelled } - let written = libssh2_sftp_write( - handle, base.advanced(by: offset), count - offset - ) - guard written > 0 else { - throw self.error("Unable to copy to \(remotePath)") - } - offset += written - meter.add(written) - } - } - } else if count == 0 { - break - } else if errno != EINTR { - throw SFTPError.operation("The SFTP copy relay was interrupted.") - } - } - guard meter.completed == byteCount else { - throw SFTPError.operation( - "The source changed while it was being copied " - + "(expected \(byteCount) bytes, received \(meter.completed))." - ) - } - meter.finish() - } - } - } - - private func withRemoteReadHandle( - at path: String, read: (OpaquePointer) throws -> T - ) throws -> T { - let sftp = try requireSFTP() - guard - let handle = path.withCString({ - libssh2_sftp_open_ex( - sftp, $0, UInt32(path.utf8.count), UInt(LIBSSH2_FXF_READ), 0, - Int32(LIBSSH2_SFTP_OPENFILE) - ) - }) - else { - throw error("Unable to open \(path)") - } - defer { _ = libssh2_sftp_close_handle(handle) } - return try read(handle) - } - - private func withAtomicRemoteWrite( - sftp: OpaquePointer, - destination: String, - overwrite: Bool, - temporaryTag: String = ".termind-part-", - write: (OpaquePointer) throws -> Void - ) throws { - let temporary = destination + temporaryTag + UUID().uuidString - let flags = UInt(LIBSSH2_FXF_WRITE | LIBSSH2_FXF_CREAT | LIBSSH2_FXF_TRUNC) - guard - let handle = temporary.withCString({ - libssh2_sftp_open_ex( - sftp, $0, UInt32(temporary.utf8.count), flags, 0o600, - Int32(LIBSSH2_SFTP_OPENFILE) - ) - }) - else { - throw error("Unable to create \(destination)") - } - var succeeded = false - defer { - _ = libssh2_sftp_close_handle(handle) - if !succeeded { - _ = temporary.withCString { - libssh2_sftp_unlink_ex(sftp, $0, UInt32(temporary.utf8.count)) - } - } - } - try write(handle) - try renameOnQueue( - sftp: sftp, from: temporary, to: destination, overwrite: overwrite - ) - succeeded = true - } - - private func renameOnQueue(sftp: OpaquePointer, from source: String, to destination: String, overwrite: Bool) throws - { - let flags = overwrite ? Int(LIBSSH2_SFTP_RENAME_OVERWRITE) : 0 - let result = source.withCString { sourceBuffer in - destination.withCString { destinationBuffer in - libssh2_sftp_rename_ex( - sftp, sourceBuffer, UInt32(source.utf8.count), - destinationBuffer, UInt32(destination.utf8.count), flags - ) - } - } - guard result == 0 else { throw error("Unable to finish \(destination)") } - } - - private func requireSFTP() throws -> OpaquePointer { - guard let sftp else { throw SFTPError.notConnected } - return sftp - } - - private func error(_ prefix: String) -> SFTPError { - guard let sftp else { return .operation(prefix) } - let sftpCode = libssh2_sftp_last_error(sftp) - let sessionCode = session.map(libssh2_session_last_errno) ?? 0 - let message = "\(prefix): SFTP error \(sftpCode)" - let lostSFTPCodes = [ - UInt(LIBSSH2_FX_NO_CONNECTION), UInt(LIBSSH2_FX_CONNECTION_LOST), - ] - let lostSessionCodes = [ - LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, - LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED, - ] - if lostSFTPCodes.contains(sftpCode) || lostSessionCodes.contains(sessionCode) { - reportConnectionFailure(message) - return .connectionLost(message) - } - return .operation(message) - } - - private func cleanup() { - keepaliveTimer?.cancel() - keepaliveTimer = nil - if let sftp { - _ = libssh2_sftp_shutdown(sftp) - self.sftp = nil - } - if let session { - _ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed SFTP", "") - _ = libssh2_session_free(session) - self.session = nil - } - routeTransport?.close() - routeTransport = nil - connectionFailure = nil - didReportConnectionFailure = false - } - - private func startKeepalive(for session: OpaquePointer) { - libssh2_keepalive_config(session, 1, 15) - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + 15, repeating: 15) - timer.setEventHandler { [weak self] in - guard let self, self.session == session else { return } - var secondsToNext: Int32 = 0 - let result = libssh2_keepalive_send(session, &secondsToNext) - if result < 0, result != LIBSSH2_ERROR_EAGAIN { - let error = SSHSessionWorker.sessionError( - session, prefix: "SFTP keepalive failed" - ) - self.reportConnectionFailure(error.localizedDescription) - } - } - keepaliveTimer = timer - timer.resume() - } - - private func reportConnectionFailure(_ message: String) { - guard !didReportConnectionFailure else { return } - didReportConnectionFailure = true - connectionFailure?(message) - } - - private static func join(_ base: String, _ component: String) -> String { - base == "/" ? "/\(component)" : "\(base)/\(component)" - } - - private static func remoteFile( - path: String, name: String, attributes: LIBSSH2_SFTP_ATTRIBUTES - ) -> RemoteFile { - let mode = UInt32(attributes.permissions) - let kind: RemoteFile.Kind - if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFDIR) { - kind = .directory - } else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFREG) { - kind = .file - } else if mode & UInt32(LIBSSH2_SFTP_S_IFMT) == UInt32(LIBSSH2_SFTP_S_IFLNK) { - kind = .symbolicLink - } else if mode == 0 { - kind = .unknown - } else { - kind = .special - } - return RemoteFile( - path: path, name: name, kind: kind, size: Int64(attributes.filesize), - modifiedAt: attributes.mtime == 0 ? nil : Date(timeIntervalSince1970: TimeInterval(attributes.mtime)), - permissions: mode, ownerUID: UInt32(attributes.uid), ownerGID: UInt32(attributes.gid) - ) - } -} +import TermindInfrastructure +import TermindRemoteInfrastructure + +typealias RemoteFile = TermindRemoteInfrastructure.RemoteFile +typealias TransferCancellationToken = TermindRemoteInfrastructure.TransferCancellationToken +typealias SFTPProgress = TermindRemoteInfrastructure.SFTPProgress +typealias SFTPClientProtocol = TermindRemoteInfrastructure.SFTPClientProtocol +typealias SFTPError = TermindRemoteInfrastructure.SFTPError +typealias SFTPClient = TermindRemoteInfrastructure.SFTPClient diff --git a/TFM/Infrastructure/SSH/SSHExecSession.swift b/TFM/Infrastructure/SSH/SSHExecSession.swift index 28b98f03..902ceffc 100644 --- a/TFM/Infrastructure/SSH/SSHExecSession.swift +++ b/TFM/Infrastructure/SSH/SSHExecSession.swift @@ -1,385 +1,8 @@ -import CSSH2 -import Darwin -import Foundation +import TermindInfrastructure +import TermindRemoteInfrastructure -nonisolated struct SSHExecResult: Hashable, Sendable { - var stdout: String - var stderr: String - var exitCode: Int32 - var signal: String? - var stdoutTruncated: Bool - var stderrTruncated: Bool -} - -/// The one failure that is safe to retry on a fresh connection. -/// -/// It is thrown only while the remote command is provably *not running*: the -/// session was already gone, or the channel could not be opened or started. -/// Every failure after `process_startup` succeeds is rethrown unchanged, because -/// by then the command may have partially run and a transparent retry could -/// delete something twice. -nonisolated enum SSHExecSessionError: LocalizedError, Sendable { - case channelUnavailable(any Error) - - var errorDescription: String? { - switch self { - case .channelUnavailable(let underlying): underlying.localizedDescription - } - } -} - -/// A long-lived authenticated SSH session that runs each command on its own -/// non-PTY channel. -/// -/// Splitting connect from execute is the whole point: the handshake, host-key -/// check, and signature are paid once per host instead of once per command, and -/// a jump-host route stops rebuilding every hop for every `ls`. Semantics are -/// unchanged — a command still gets a private channel and a private -/// `/bin/sh -lc`, so there is no shared shell state and no cwd carry-over. -nonisolated protocol SSHExecSessionProtocol: AnyObject, Sendable { - func connect(using configuration: SSHConnectionConfiguration) async throws - func execute( - _ command: String, workingDirectory: String?, stdin: Data?, - timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? - ) async throws -> SSHExecResult - func disconnect() async -} - -/// Called with each chunk the command produces, and which stream it came from — -/// `false` for stdout, `true` for stderr. -/// -/// It runs on the session's own serial queue, between two `libssh2` reads, so it -/// must return promptly and must never await: anything it blocks on stalls every -/// command on that host. Appending to a lock-guarded buffer is the intended -/// shape. -typealias SSHExecOutputHandler = @Sendable (Data, Bool) -> Void - -actor SSHExecSession: SSHExecSessionProtocol { - private nonisolated let worker = SSHExecWorker() - - func connect(using configuration: SSHConnectionConfiguration) async throws { - try await worker.connect(using: configuration) - } - - /// Actor-isolated, so commands on one session queue behind each other. That - /// is not incidental: a libssh2 session cannot be driven from two places at - /// once, and the worker's queue would serialize them anyway. - func execute( - _ command: String, workingDirectory: String?, stdin: Data?, - timeout: TimeInterval, outputLimit: Int, onOutput: SSHExecOutputHandler? - ) async throws -> SSHExecResult { - let cancellation = TransferCancellationToken() - return try await withTaskCancellationHandler { - try await worker.execute( - command, workingDirectory: workingDirectory, stdin: stdin, - timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, - onOutput: onOutput - ) - } onCancel: { - cancellation.cancel() - } - } - - func disconnect() async { await worker.disconnect() } -} - -private nonisolated final class SSHExecWorker: @unchecked Sendable { - /// Every libssh2 call for this session happens here, the idle keepalive - /// included. libssh2 sessions are not thread-safe, and serializing on one - /// queue is what makes reuse across commands safe at all. - private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.exec") - private var routeTransport: SSHRoutedSocket? - private var session: OpaquePointer? - private var keepaliveTimer: (any DispatchSourceTimer)? - /// Set when the transport is known to be gone. The next `execute` reports - /// `channelUnavailable` instead of calling into a session that cannot answer. - private var isDead = false - - deinit { cleanup() } - - func connect(using configuration: SSHConnectionConfiguration) async throws { - try await queue.perform { - self.cleanup() - let (transport, session) = try SSHSessionWorker.openAuthenticatedSession( - using: configuration, timeout: 20 - ) - self.routeTransport = transport - self.session = session - self.isDead = false - self.startKeepalive(for: session) - } - } - - func disconnect() async { await queue.perform { self.cleanup() } } - - func execute( - _ command: String, workingDirectory: String?, stdin: Data?, - timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, - onOutput: SSHExecOutputHandler? - ) async throws -> SSHExecResult { - try await queue.perform { - do { - return try self.executeOnQueue( - command, workingDirectory: workingDirectory, stdin: stdin, - timeout: timeout, outputLimit: outputLimit, cancellation: cancellation, - onOutput: onOutput - ) - } catch { - // Still on the queue, and the channel defer has already run, so - // this is the right moment to give the socket back rather than - // leave a corpse in the pool for the idle sweep to find. - if self.isDead { self.cleanup() } - throw error - } - } - } - - private func executeOnQueue( - _ command: String, workingDirectory: String?, stdin: Data?, - timeout: TimeInterval, outputLimit: Int, cancellation: TransferCancellationToken, - onOutput: SSHExecOutputHandler? - ) throws -> SSHExecResult { - guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - throw TermindSSHError.library("The command is empty.") - } - guard let session, !isDead else { - throw SSHExecSessionError.channelUnavailable(TermindSSHError.disconnected) - } - // Blocking for the two setup calls, non-blocking for the pump below. - libssh2_session_set_blocking(session, 1) - guard - let channel = libssh2_channel_open_ex( - session, "session", 7, 2 * 1_024 * 1_024, 32 * 1_024, nil, 0 - ) - else { - throw notStarted(session, prefix: "Unable to open SSH exec channel") - } - // Only a command that ran to completion leaves a channel worth closing - // politely, and a session worth keeping. - var completed = false - defer { - if completed { - libssh2_session_set_blocking(session, 1) - _ = libssh2_channel_free(channel) - } else { - // Timed out, cancelled, or the transport failed: the command may - // still be running and the channel is mid-stream. Blocking here - // to close it courteously would stall every later command on this - // host, so give the whole session up instead — `cleanup` frees - // it, and freeing a session reclaims its channels with it. - isDead = true - } - } - - let effective: String - if let cwd = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines), - !cwd.isEmpty - { - effective = "cd -- \(Self.shellQuote(cwd)) && /bin/sh -lc \(Self.shellQuote(command))" - } else { - effective = "/bin/sh -lc \(Self.shellQuote(command))" - } - let started = effective.withCString { - libssh2_channel_process_startup(channel, "exec", 4, $0, UInt32(effective.utf8.count)) - } - guard started == 0 else { - throw notStarted(session, prefix: "Unable to start remote command") - } - // Past this line the command is running. Nothing below may be retried. - libssh2_session_set_blocking(session, 0) - - var pendingInput = stdin ?? Data() - if !pendingInput.isEmpty, pendingInput.last != 0x0A { pendingInput.append(0x0A) } - var inputOffset = 0 - var sentEOF = pendingInput.isEmpty - if sentEOF { _ = libssh2_channel_send_eof(channel) } - var standardOut = Data() - var standardError = Data() - var outTruncated = false - var errorTruncated = false - var buffer = [CChar](repeating: 0, count: 16 * 1_024) - let deadline = Date().addingTimeInterval(max(1, min(timeout, 15 * 60))) - var lastKeepalive = Date() - - while libssh2_channel_eof(channel) == 0 { - if cancellation.isCancelled { throw CancellationError() } - if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") } - var progressed = false - if inputOffset < pendingInput.count { - let written = pendingInput.withUnsafeBytes { bytes in - libssh2_channel_write_ex( - channel, 0, - bytes.baseAddress!.advanced(by: inputOffset) - .assumingMemoryBound(to: CChar.self), - bytes.count - inputOffset - ) - } - if written > 0 { - inputOffset += written - progressed = true - } else if written != LIBSSH2_ERROR_EAGAIN { - throw failed(session, prefix: "Unable to write command input") - } - } else if !sentEOF { - let result = libssh2_channel_send_eof(channel) - if result == 0 { - sentEOF = true - progressed = true - } else if result != LIBSSH2_ERROR_EAGAIN { - throw failed(session, prefix: "Unable to finish command input") - } - } - for stream in [Int32(0), Int32(1)] { - while true { - let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count) - if count > 0 { - progressed = true - let chunk = Data(bytes: buffer, count: count) - onOutput?(chunk, stream != 0) - if stream == 0 { - Self.appendCapped( - chunk, to: &standardOut, limit: outputLimit, - truncated: &outTruncated - ) - } else { - Self.appendCapped( - chunk, to: &standardError, limit: outputLimit, - truncated: &errorTruncated - ) - } - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - break - } else { - throw failed(session, prefix: "Unable to read command output") - } - } - } - if !progressed { - // The idle timer cannot fire while this queue is busy, so a - // long-running quiet command has to keep its own session alive. - if Date().timeIntervalSince(lastKeepalive) >= 30 { - lastKeepalive = Date() - var next = Int32(0) - let sent = libssh2_keepalive_send(session, &next) - if sent < 0, sent != LIBSSH2_ERROR_EAGAIN { - throw failed(session, prefix: "SSH keepalive failed") - } - } - usleep(10_000) - } - } - // Drain bytes buffered alongside EOF. - for stream in [Int32(0), Int32(1)] { - while true { - let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count) - guard count > 0 else { break } - let chunk = Data(bytes: buffer, count: count) - onOutput?(chunk, stream != 0) - if stream == 0 { - Self.appendCapped( - chunk, to: &standardOut, limit: outputLimit, truncated: &outTruncated - ) - } else { - Self.appendCapped( - chunk, to: &standardError, limit: outputLimit, truncated: &errorTruncated - ) - } - } - } - while true { - let closed = libssh2_channel_close(channel) - if closed == 0 { break } - if closed != LIBSSH2_ERROR_EAGAIN { - throw failed(session, prefix: "Unable to close SSH exec channel") - } - if cancellation.isCancelled { throw CancellationError() } - if Date() >= deadline { throw TermindSSHError.library("The remote command timed out.") } - usleep(10_000) - } - // The remote is done and the channel is closed, so the session is clean - // and can carry the next command. - completed = true - var exitSignal: UnsafeMutablePointer? - var exitSignalLength = 0 - let signalResult = libssh2_channel_get_exit_signal( - channel, &exitSignal, &exitSignalLength, nil, nil, nil, nil - ) - let signal: String? - if signalResult == 0, let exitSignal { - signal = String( - decoding: UnsafeBufferPointer( - start: exitSignal, count: exitSignalLength - ).map(UInt8.init(bitPattern:)), as: UTF8.self) - libssh2_free(session, exitSignal) - } else { - signal = nil - } - return SSHExecResult( - stdout: String(decoding: standardOut, as: UTF8.self), - stderr: String(decoding: standardError, as: UTF8.self), - exitCode: Int32(libssh2_channel_get_exit_status(channel)), - signal: signal, - stdoutTruncated: outTruncated, stderrTruncated: errorTruncated - ) - } - - /// The command never started, so the caller may open a fresh connection and - /// try again exactly once. - private func notStarted(_ session: OpaquePointer, prefix: String) -> SSHExecSessionError { - SSHExecSessionError.channelUnavailable(failed(session, prefix: prefix)) - } - - /// Records whether the session is still usable, then describes the failure. - private func failed(_ session: OpaquePointer, prefix: String) -> TermindSSHError { - let transport: [Int32] = [ - LIBSSH2_ERROR_SOCKET_SEND, LIBSSH2_ERROR_SOCKET_RECV, - LIBSSH2_ERROR_SOCKET_DISCONNECT, LIBSSH2_ERROR_SOCKET_TIMEOUT, - LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_CHANNEL_CLOSED, - ] - if transport.contains(libssh2_session_last_errno(session)) { isDead = true } - return SSHSessionWorker.sessionError(session, prefix: prefix) - } - - private func startKeepalive(for session: OpaquePointer) { - libssh2_keepalive_config(session, 1, 30) - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now() + 30, repeating: 30) - timer.setEventHandler { [weak self] in - guard let self, self.session == session else { return } - var secondsToNext: Int32 = 0 - let result = libssh2_keepalive_send(session, &secondsToNext) - if result < 0, result != LIBSSH2_ERROR_EAGAIN { - self.isDead = true - self.cleanup() - } - } - keepaliveTimer = timer - timer.resume() - } - - private func cleanup() { - keepaliveTimer?.cancel() - keepaliveTimer = nil - if let session { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the exec session", "" - ) - _ = libssh2_session_free(session) - self.session = nil - } - routeTransport?.close() - routeTransport = nil - } - - private static func shellQuote(_ value: String) -> String { - "'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'" - } - - private static func appendCapped( - _ chunk: Data, to destination: inout Data, limit: Int, truncated: inout Bool - ) { - let remaining = max(0, limit - destination.count) - if chunk.count > remaining { truncated = true } - if remaining > 0 { destination.append(chunk.prefix(remaining)) } - } -} +typealias SSHExecResult = TermindRemoteInfrastructure.SSHExecResult +typealias SSHExecSessionError = TermindRemoteInfrastructure.SSHExecSessionError +typealias SSHExecSessionProtocol = TermindRemoteInfrastructure.SSHExecSessionProtocol +typealias SSHExecOutputHandler = TermindRemoteInfrastructure.SSHExecOutputHandler +typealias SSHExecSession = TermindRemoteInfrastructure.SSHExecSession diff --git a/TFM/Infrastructure/SSH/SSHForwardingSession.swift b/TFM/Infrastructure/SSH/SSHForwardingSession.swift index d617a821..3472cc00 100644 --- a/TFM/Infrastructure/SSH/SSHForwardingSession.swift +++ b/TFM/Infrastructure/SSH/SSHForwardingSession.swift @@ -1,520 +1,4 @@ -import CSSH2 -import Darwin -import Foundation -import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure -actor SSHForwardingSession { - typealias ConnectionHandler = @Sendable (PortForwardingRule.ID, Int) -> Void - typealias FailureHandler = @Sendable (String) -> Void - - private let worker: SSHForwardingWorker - - init( - connectionHandler: @escaping ConnectionHandler, - failureHandler: @escaping FailureHandler - ) { - worker = SSHForwardingWorker( - connectionHandler: connectionHandler, failureHandler: failureHandler - ) - } - - func connect(_ configuration: SSHConnectionConfiguration) async throws { - try await worker.connect(configuration) - } - - func start(_ rule: PortForwardingRule) async throws { - try await worker.start(rule) - } - - func stop(_ id: PortForwardingRule.ID) async { - await worker.stop(id) - } - - func disconnect() async { - await worker.disconnect() - } -} - -private nonisolated final class SSHForwardingWorker: @unchecked Sendable { - private static let initialization: Void = { - precondition(libssh2_init(0) == 0, "libssh2 initialization failed") - }() - - private final class RuleContext { - let rule: PortForwardingRule - var socket: Int32 = -1 - var remoteListener: OpaquePointer? - var flows: [UUID: Flow] = [:] - - init(rule: PortForwardingRule) { self.rule = rule } - } - - private final class Flow { - enum Phase { case greeting, request, forwarding } - let id = UUID() - var socket: Int32 - var channel: OpaquePointer? - var phase: Phase - var handshake = Data() - var toChannel = Data() - var toSocket = Data() - var socketEOF = false - var channelEOF = false - var socketWriteClosed = false - var channelWriteClosed = false - - init(socket: Int32, dynamic: Bool, channel: OpaquePointer? = nil) { - self.socket = socket - self.phase = dynamic ? .greeting : .forwarding - self.channel = channel - } - } - - private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.forwarding") - private let connectionHandler: SSHForwardingSession.ConnectionHandler - private let failureHandler: SSHForwardingSession.FailureHandler - private var socket: Int32 = -1 - private var routeTransport: SSHRoutedSocket? - private var session: OpaquePointer? - private var rules: [PortForwardingRule.ID: RuleContext] = [:] - private var timer: (any DispatchSourceTimer)? - - init( - connectionHandler: @escaping SSHForwardingSession.ConnectionHandler, - failureHandler: @escaping SSHForwardingSession.FailureHandler - ) { - _ = Self.initialization - self.connectionHandler = connectionHandler - self.failureHandler = failureHandler - } - - func connect(_ configuration: SSHConnectionConfiguration) async throws { - try await withCheckedThrowingContinuation { continuation in - queue.async { [self] in - do { - cleanup() - routeTransport = try SSHRoutedSocket.open( - host: configuration.host, port: configuration.port, - proxy: configuration.proxy, jumps: configuration.jumps, timeout: 20 - ) - guard let routeTransport else { throw TermindSSHError.disconnected } - socket = routeTransport.socket - guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library(String(localized: "Unable to create an SSH forwarding session.")) - } - session = created - libssh2_session_set_blocking(created, 1) - guard libssh2_session_handshake(created, socket) == 0 else { - throw SSHSessionWorker.sessionError(created, prefix: String(localized: "SSH handshake failed")) - } - let observed = try SSHSessionWorker.observation( - host: configuration.host, port: configuration.port, session: created - ) - try SSHSessionWorker.validateHostKey( - observed, expected: configuration.expectedHostKeys - ) - try SSHSessionWorker.authenticate( - username: configuration.username, - credentials: configuration.credentials, session: created - ) - libssh2_session_set_blocking(created, 0) - libssh2_keepalive_config(created, 1, 20) - startTimer() - continuation.resume() - } catch { - cleanup() - continuation.resume(throwing: error) - } - } - } - } - - func start(_ rule: PortForwardingRule) async throws { - try await withCheckedThrowingContinuation { continuation in - queue.async { [self] in - do { - guard let session else { throw TermindSSHError.disconnected } - if rules[rule.id] != nil { - continuation.resume() - return - } - let context = RuleContext(rule: rule) - switch rule.kind { - case .local, .dynamic: - do { - context.socket = try makeListener( - host: rule.bindHost, port: rule.bindPort - ) - } catch { - throw TermindSSHError.route( - String( - localized: "Unable to listen on \(rule.bindAddress): \(error.localizedDescription)") - ) - } - case .remote: - var boundPort: Int32 = 0 - let listener = rule.bindHost.withCString { host in - libssh2_channel_forward_listen_ex( - session, host, Int32(rule.bindPort), &boundPort, 64 - ) - } - guard let listener else { - throw TermindSSHError.route( - SSHSessionWorker.sessionError( - session, prefix: String(localized: "Unable to start remote forwarding") - ).localizedDescription - ) - } - context.remoteListener = listener - } - rules[rule.id] = context - connectionHandler(rule.id, 0) - continuation.resume() - } catch { continuation.resume(throwing: error) } - } - } - } - - func stop(_ id: PortForwardingRule.ID) async { - await withCheckedContinuation { continuation in - queue.async { [self] in - if let context = rules.removeValue(forKey: id) { close(context) } - continuation.resume() - } - } - } - - func disconnect() async { - await withCheckedContinuation { continuation in - queue.async { [self] in - cleanup() - continuation.resume() - } - } - } - - private func startTimer() { - timer?.cancel() - let source = DispatchSource.makeTimerSource(queue: queue) - source.schedule(deadline: .now(), repeating: .milliseconds(12), leeway: .milliseconds(4)) - source.setEventHandler { [weak self] in self?.pump() } - timer = source - source.resume() - } - - private func pump() { - guard let session else { return } - for context in Array(rules.values) { - switch context.rule.kind { - case .local, .dynamic: acceptLocal(context, session: session) - case .remote: acceptRemote(context) - } - for flow in Array(context.flows.values) { pump(flow, in: context, session: session) } - connectionHandler(context.rule.id, context.flows.count) - } - var next = Int32(0) - let keepalive = libssh2_keepalive_send(session, &next) - if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { - let message = SSHSessionWorker.sessionError( - session, prefix: String(localized: "The forwarding SSH connection closed") - ).localizedDescription - cleanup() - failureHandler(message) - } - } - - private func acceptLocal(_ context: RuleContext, session: OpaquePointer) { - while true { - let accepted = Darwin.accept(context.socket, nil, nil) - guard accepted >= 0 else { break } - makeNonblocking(accepted) - let flow = Flow(socket: accepted, dynamic: context.rule.kind == .dynamic) - if context.rule.kind == .local { - guard let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort else { - Darwin.close(accepted) - continue - } - flow.channel = targetHost.withCString { target in - libssh2_channel_direct_tcpip_ex(session, target, Int32(targetPort), "127.0.0.1", 0) - } - guard flow.channel != nil else { - Darwin.close(accepted) - continue - } - } - context.flows[flow.id] = flow - } - } - - private func acceptRemote(_ context: RuleContext) { - guard let listener = context.remoteListener, - let targetHost = context.rule.targetHost, let targetPort = context.rule.targetPort - else { return } - while let channel = libssh2_channel_forward_accept(listener) { - do { - let local = try SSHSocketConnector.open( - host: targetHost, port: targetPort, timeout: 5 - ) - makeNonblocking(local) - let flow = Flow(socket: local, dynamic: false, channel: channel) - context.flows[flow.id] = flow - } catch { - _ = libssh2_channel_free(channel) - } - } - } - - private func pump(_ flow: Flow, in context: RuleContext, session: OpaquePointer) { - let highWaterMark = 2 * 1024 * 1024 - if !flow.socketEOF, - (flow.phase == .forwarding ? flow.toChannel.count : flow.handshake.count) < highWaterMark - { - var buffer = [UInt8](repeating: 0, count: 32 * 1024) - let count = Darwin.recv(flow.socket, &buffer, buffer.count, 0) - if count > 0 { - let data = Data(buffer.prefix(count)) - if flow.phase == .forwarding { - flow.toChannel.append(data) - } else { - flow.handshake.append(data) - if flow.handshake.count > 64 * 1024 { - close(flow, in: context) - return - } - handleSOCKSHandshake(flow, session: session) - } - } else if count == 0 { - flow.socketEOF = true - } else if errno != EAGAIN && errno != EWOULDBLOCK { - close(flow, in: context) - return - } - } - - if let channel = flow.channel { - while !flow.toChannel.isEmpty { - let written = flow.toChannel.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } - return libssh2_channel_write_ex(channel, 0, base, bytes.count) - } - if written > 0 { - flow.toChannel.removeFirst(written) - } else if written == LIBSSH2_ERROR_EAGAIN { - break - } else { - close(flow, in: context) - return - } - } - if flow.socketEOF, flow.toChannel.isEmpty, !flow.channelWriteClosed { - let result = libssh2_channel_send_eof(channel) - if result == 0 { - flow.channelWriteClosed = true - } else if result != LIBSSH2_ERROR_EAGAIN { - close(flow, in: context) - return - } - } - if flow.toSocket.count < highWaterMark { - var buffer = [CChar](repeating: 0, count: 32 * 1024) - while flow.toSocket.count < highWaterMark { - let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count) - if count > 0 { - flow.toSocket.append(Data(bytes: buffer, count: count)) - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - break - } else { - close(flow, in: context) - return - } - } - } - if libssh2_channel_eof(channel) == 1 { flow.channelEOF = true } - } - - while !flow.toSocket.isEmpty { - let sent = flow.toSocket.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress else { return 0 } - return Darwin.send(flow.socket, base, bytes.count, 0) - } - if sent > 0 { - flow.toSocket.removeFirst(sent) - } else if errno == EAGAIN || errno == EWOULDBLOCK { - break - } else { - close(flow, in: context) - return - } - } - if flow.channelEOF, flow.toSocket.isEmpty, !flow.socketWriteClosed { - _ = Darwin.shutdown(flow.socket, SHUT_WR) - flow.socketWriteClosed = true - } - if flow.socketEOF, - flow.channel == nil || flow.channelEOF, - flow.toChannel.isEmpty, flow.toSocket.isEmpty - { - close(flow, in: context) - } - } - - private func handleSOCKSHandshake(_ flow: Flow, session: OpaquePointer) { - switch flow.phase { - case .greeting: - guard flow.handshake.count >= 2 else { return } - let methods = Int(flow.handshake[1]) - guard flow.handshake.count >= 2 + methods else { return } - guard flow.handshake[0] == 0x05, - flow.handshake[2..<(2 + methods)].contains(0x00) - else { - flow.toSocket.append(contentsOf: [0x05, 0xff]) - flow.socketEOF = true - return - } - flow.handshake.removeFirst(2 + methods) - flow.toSocket.append(contentsOf: [0x05, 0x00]) - flow.phase = .request - handleSOCKSHandshake(flow, session: session) - case .request: - guard flow.handshake.count >= 4 else { return } - guard flow.handshake[0] == 0x05, flow.handshake[1] == 0x01 else { - flow.toSocket.append(socksReply(0x07)) - flow.socketEOF = true - return - } - let parsed: (String, Int, Int)? - switch flow.handshake[3] { - case 0x01: - guard flow.handshake.count >= 10 else { return } - parsed = ( - flow.handshake[4..<8].map(String.init).joined(separator: "."), - Int(flow.handshake[8]) << 8 | Int(flow.handshake[9]), 10 - ) - case 0x03: - guard flow.handshake.count >= 5 else { return } - let length = Int(flow.handshake[4]) - guard flow.handshake.count >= 7 + length else { return } - parsed = ( - String(decoding: flow.handshake[5..<(5 + length)], as: UTF8.self), - Int(flow.handshake[5 + length]) << 8 | Int(flow.handshake[6 + length]), - 7 + length - ) - case 0x04: - guard flow.handshake.count >= 22 else { return } - var address = in6_addr() - _ = withUnsafeMutableBytes(of: &address) { destination in - flow.handshake[4..<20].copyBytes(to: destination) - } - var chars = [CChar](repeating: 0, count: Int(INET6_ADDRSTRLEN)) - _ = inet_ntop(AF_INET6, &address, &chars, socklen_t(chars.count)) - let end = chars.firstIndex(of: 0) ?? chars.endIndex - parsed = ( - String(decoding: chars[.. Data { - Data([0x05, code, 0x00, 0x01, 0, 0, 0, 0, 0, 0]) - } - - private func makeListener(host: String, port: Int) throws -> Int32 { - var hints = addrinfo() - hints.ai_family = AF_UNSPEC - hints.ai_socktype = SOCK_STREAM - hints.ai_flags = AI_PASSIVE - var result: UnsafeMutablePointer? - let resolved = getaddrinfo(host, String(port), &hints, &result) - guard resolved == 0, let first = result else { - throw TermindSSHError.socket(String(cString: gai_strerror(resolved))) - } - defer { freeaddrinfo(first) } - var cursor: UnsafeMutablePointer? = first - while let info = cursor?.pointee { - let candidate = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol) - if candidate >= 0 { - var yes: Int32 = 1 - setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size)) - if Darwin.bind(candidate, info.ai_addr, info.ai_addrlen) == 0, - Darwin.listen(candidate, 128) == 0 - { - makeNonblocking(candidate) - return candidate - } - Darwin.close(candidate) - } - cursor = info.ai_next - } - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - - private func makeNonblocking(_ socket: Int32) { - let flags = fcntl(socket, F_GETFL, 0) - if flags >= 0 { _ = fcntl(socket, F_SETFL, flags | O_NONBLOCK) } - } - - private func close(_ flow: Flow, in context: RuleContext) { - context.flows[flow.id] = nil - if flow.socket >= 0 { - Darwin.close(flow.socket) - flow.socket = -1 - } - if let channel = flow.channel { - _ = libssh2_channel_close(channel) - _ = libssh2_channel_free(channel) - flow.channel = nil - } - } - - private func close(_ context: RuleContext) { - for flow in Array(context.flows.values) { close(flow, in: context) } - if context.socket >= 0 { - Darwin.close(context.socket) - context.socket = -1 - } - if let listener = context.remoteListener { - _ = libssh2_channel_forward_cancel(listener) - context.remoteListener = nil - } - } - - private func cleanup() { - timer?.cancel() - timer = nil - for context in Array(rules.values) { close(context) } - rules.removeAll() - if let session { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind stopped forwarding", "" - ) - _ = libssh2_session_free(session) - self.session = nil - } - socket = -1 - routeTransport?.close() - routeTransport = nil - } -} +typealias SSHForwardingSession = TermindRemoteInfrastructure.SSHForwardingSession diff --git a/TFM/Infrastructure/SSH/SSHKeyMaterial.swift b/TFM/Infrastructure/SSH/SSHKeyMaterial.swift index dafab28e..f559f34d 100644 --- a/TFM/Infrastructure/SSH/SSHKeyMaterial.swift +++ b/TFM/Infrastructure/SSH/SSHKeyMaterial.swift @@ -1,320 +1,5 @@ -import CSSH2 -import CryptoKit -import Foundation -import OpenSSL -import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure -nonisolated enum SSHKeyMaterialError: LocalizedError { - case invalidPrivateKey(String) - case unsupportedAlgorithm(String) - case generationFailed(String) - - var errorDescription: String? { - switch self { - case .invalidPrivateKey(let detail): - String(localized: "The private key could not be read: \(detail)") - case .unsupportedAlgorithm(let name): - String(localized: "Unsupported SSH key algorithm: \(name)") - case .generationFailed(let detail): - String(localized: "Key generation failed: \(detail)") - } - } -} - -nonisolated struct SSHKeyMaterial: Sendable { - nonisolated enum Cipher: String, CaseIterable, Identifiable, Hashable, Sendable { - case aes256 - case aes128 - case tripleDES - - var id: String { rawValue } - - var displayName: String { - switch self { - case .aes256: "AES-256" - case .aes128: "AES-128" - case .tripleDES: "3DES" - } - } - - fileprivate var openSSLName: String { - switch self { - case .aes256: "AES-256-CBC" - case .aes128: "AES-128-CBC" - case .tripleDES: "DES-EDE3-CBC" - } - } - } - - nonisolated enum Generation: Hashable, Sendable { - case ed25519 - case ecdsa(bits: Int) - case rsa(bits: Int) - } - - var privateKey: String - var publicKey: String - var algorithm: SSHKeyAlgorithm - var fingerprint: String - var keySize: Int? - - static func generate( - _ generation: Generation, comment: String, passphrase: String?, - cipher: Cipher = .aes256, rounds: Int = 100 - ) throws -> SSHKeyMaterial { - let key = try generateOpenSSLKey(generation) - defer { EVP_PKEY_free(key) } - let passphrase = normalizedPassphrase(passphrase) - let privateKey = try encodePrivateKey( - key, passphrase: passphrase, cipher: cipher, rounds: rounds - ) - return try inspect( - privateKey: privateKey, passphrase: passphrase, - comment: comment, keySize: generation.keySize - ) - } - - static func inspect( - privateKey: String, passphrase: String?, comment: String = "", - keySize: Int? = nil - ) throws -> SSHKeyMaterial { - _ = initialization - guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw SSHKeyMaterialError.invalidPrivateKey("Unable to create a libssh2 parser session.") - } - defer { _ = libssh2_session_free(session) } - - var method: UnsafeMutablePointer? - var methodLength = 0 - var blob: UnsafeMutablePointer? - var blobLength = 0 - let result = privateKey.withCString { privateBuffer in - let parse: (UnsafePointer?) -> Int32 = { passphraseBuffer in - libssh2PublicKeyFromPrivateMemory( - session, &method, &methodLength, &blob, &blobLength, - privateBuffer, privateKey.utf8.count, passphraseBuffer - ) - } - return normalizedPassphrase(passphrase)?.withCString(parse) ?? parse(nil) - } - defer { - if let method { libssh2_free(session, method) } - if let blob { libssh2_free(session, blob) } - } - guard result == 0, let method, let blob, methodLength > 0, blobLength > 0 else { - throw SSHKeyMaterialError.invalidPrivateKey(sessionError(session, code: result)) - } - - let methodBytes = UnsafeBufferPointer(start: method, count: methodLength) - let name = String(decoding: methodBytes, as: UTF8.self) - let publicBlob = Data(bytes: blob, count: blobLength) - let algorithm = try algorithm(named: name) - let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines) - let publicKey = - "\(name) \(publicBlob.base64EncodedString())" - + (trimmedComment.isEmpty ? "" : " \(trimmedComment)") - return SSHKeyMaterial( - privateKey: privateKey, publicKey: publicKey, algorithm: algorithm, - fingerprint: fingerprint(of: publicBlob), - keySize: keySize ?? inferredKeySize(for: algorithm, publicBlob: publicBlob) - ) - } - - private static let initialization: Void = { - precondition(libssh2_init(0) == 0, "libssh2 initialization failed") - }() - - private static func generateOpenSSLKey(_ generation: Generation) throws -> OpaquePointer { - let name: String - switch generation { - case .ed25519: name = "ED25519" - case .ecdsa: name = "EC" - case .rsa: name = "RSA" - } - guard let context = EVP_PKEY_CTX_new_from_name(nil, name, nil) else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - defer { EVP_PKEY_CTX_free(context) } - guard EVP_PKEY_keygen_init(context) > 0 else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - switch generation { - case .ed25519: - break - case .ecdsa(let bits): - let curve: String - switch bits { - case 256: curve = "prime256v1" - case 384: curve = "secp384r1" - case 521: curve = "secp521r1" - default: throw SSHKeyMaterialError.unsupportedAlgorithm("ECDSA P-\(bits)") - } - guard curve.withCString({ EVP_PKEY_CTX_set_group_name(context, $0) }) > 0 else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - case .rsa(let bits): - guard [2048, 3072, 4096].contains(bits) else { - throw SSHKeyMaterialError.unsupportedAlgorithm("RSA \(bits)") - } - guard EVP_PKEY_CTX_set_rsa_keygen_bits(context, Int32(bits)) > 0 else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - } - var key: OpaquePointer? - guard EVP_PKEY_keygen(context, &key) > 0, let key else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - return key - } - - private static func encodePrivateKey( - _ key: OpaquePointer, passphrase: String?, cipher: Cipher, rounds: Int - ) throws -> String { - _ = initialization - guard let output = BIO_new(BIO_s_mem()) else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - defer { BIO_free(output) } - let result: Int32 - if let passphrase { - guard rounds > 0 else { - throw SSHKeyMaterialError.generationFailed( - String(localized: "Rounds must be greater than zero.") - ) - } - guard let keyInfo = EVP_PKEY2PKCS8(key) else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - defer { PKCS8_PRIV_KEY_INFO_free(keyInfo) } - guard - let cipherValue = cipher.openSSLName.withCString({ - EVP_CIPHER_fetch(nil, $0, nil) - }) - else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - defer { EVP_CIPHER_free(cipherValue) } - let encrypted = passphrase.withCString { buffer in - PKCS8_encrypt_ex( - -1, cipherValue, buffer, Int32(passphrase.utf8.count), - nil, 0, Int32(rounds), keyInfo, nil, nil - ) - } - guard let encrypted else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - defer { X509_SIG_free(encrypted) } - result = PEM_write_bio_PKCS8(output, encrypted) - } else { - result = PEM_write_bio_PrivateKey(output, key, nil, nil, 0, nil, nil) - } - guard result == 1 else { - throw SSHKeyMaterialError.generationFailed(openSSLError()) - } - let count = BIO_ctrl_pending(output) - var data = Data(count: count) - let read = data.withUnsafeMutableBytes { bytes in - BIO_read(output, bytes.baseAddress, Int32(count)) - } - guard read == count, let text = String(data: data, encoding: .utf8) else { - throw SSHKeyMaterialError.generationFailed("Unable to encode the generated private key.") - } - return text - } - - private static func algorithm(named name: String) throws -> SSHKeyAlgorithm { - switch name { - case "ssh-ed25519": .ed25519 - case "ecdsa-sha2-nistp256": .ecdsa256 - case "ecdsa-sha2-nistp384": .ecdsa384 - case "ecdsa-sha2-nistp521": .ecdsa521 - case "ssh-rsa": .rsa - default: throw SSHKeyMaterialError.unsupportedAlgorithm(name) - } - } - - private static func inferredKeySize( - for algorithm: SSHKeyAlgorithm, publicBlob: Data - ) -> Int? { - switch algorithm { - case .ed25519: 256 - case .ecdsa256: 256 - case .ecdsa384: 384 - case .ecdsa521: 521 - case .rsa: rsaKeySize(publicBlob) - } - } - - private static func rsaKeySize(_ blob: Data) -> Int? { - var offset = 0 - guard readSSHField(blob, offset: &offset) != nil, - readSSHField(blob, offset: &offset) != nil, - let modulus = readSSHField(blob, offset: &offset) - else { return nil } - let bytes = modulus.drop(while: { $0 == 0 }) - guard let first = bytes.first else { return nil } - return (bytes.count - 1) * 8 + (8 - first.leadingZeroBitCount) - } - - private static func readSSHField(_ data: Data, offset: inout Int) -> Data? { - guard offset + 4 <= data.count else { return nil } - let length = data[offset..<(offset + 4)].reduce(0) { ($0 << 8) | Int($1) } - offset += 4 - guard length >= 0, offset + length <= data.count else { return nil } - defer { offset += length } - return data[offset..<(offset + length)] - } - - private static func normalizedPassphrase(_ value: String?) -> String? { - value?.isEmpty == false ? value : nil - } - - private static func fingerprint(of blob: Data) -> String { - let digest = Data(SHA256.hash(data: blob)).base64EncodedString() - .replacingOccurrences(of: "=", with: "") - return "SHA256:\(digest)" - } - - private static func sessionError(_ session: OpaquePointer, code: Int32) -> String { - var message: UnsafeMutablePointer? - var length = Int32(0) - _ = libssh2_session_last_error(session, &message, &length, 0) - guard let message else { return "libssh2 error \(code)" } - let bytes = UnsafeBufferPointer(start: message, count: Int(length)) - .map { UInt8(bitPattern: $0) } - return String(decoding: bytes, as: UTF8.self) - } - - private static func openSSLError() -> String { - let code = ERR_get_error() - guard code != 0 else { return "Unknown OpenSSL error." } - var buffer = [CChar](repeating: 0, count: 256) - ERR_error_string_n(code, &buffer, buffer.count) - let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) } - return String(decoding: bytes, as: UTF8.self) - } -} - -extension SSHKeyMaterial.Generation { - fileprivate nonisolated var keySize: Int? { - switch self { - case .ed25519: 256 - case .ecdsa(let bits), .rsa(let bits): bits - } - } -} - -/// libssh2's authentication API invokes this same parser when the public-key -/// argument is nil. Using it here keeps imports and authentication consistent, -/// including passphrase-protected OpenSSH keys. -@_silgen_name("_libssh2_pub_priv_keyfilememory") -private nonisolated func libssh2PublicKeyFromPrivateMemory( - _ session: OpaquePointer?, - _ method: UnsafeMutablePointer?>?, - _ methodLength: UnsafeMutablePointer?, - _ publicKey: UnsafeMutablePointer?>?, - _ publicKeyLength: UnsafeMutablePointer?, - _ privateKey: UnsafePointer?, - _ privateKeyLength: Int, - _ passphrase: UnsafePointer? -) -> Int32 +typealias SSHKeyMaterialError = TermindRemoteInfrastructure.SSHKeyMaterialError +typealias SSHKeyMaterial = TermindRemoteInfrastructure.SSHKeyMaterial diff --git a/TFM/Infrastructure/SSH/SSHRoutedSocket.swift b/TFM/Infrastructure/SSH/SSHRoutedSocket.swift index 000f74bd..0aa941af 100644 --- a/TFM/Infrastructure/SSH/SSHRoutedSocket.swift +++ b/TFM/Infrastructure/SSH/SSHRoutedSocket.swift @@ -1,339 +1,4 @@ -import CSSH2 -import Darwin -import Foundation -import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure -import struct TermindDomain.Host - -/// One authenticated SSH hop used before the destination session. -nonisolated struct SSHRouteHopConfiguration: Sendable { - var id: Host.ID - var name: String - var host: String - var port: Int - var username: String - var credentials: [SSHCredentials] - var expectedHostKeys: Set - var proxy: SSHTransportProxy? -} - -/// A socket carrying bytes to the final SSH server, either directly or through -/// a chain of authenticated direct-tcpip channels. The caller owns the final -/// libssh2 session; this object owns every preceding hop and its bridge pump. -nonisolated final class SSHRoutedSocket: @unchecked Sendable { - let socket: Int32 - private var bridges: [SSHChannelSocketBridge] - private var isClosed = false - - private init(socket: Int32, bridges: [SSHChannelSocketBridge]) { - self.socket = socket - self.bridges = bridges - } - - deinit { close() } - - static func open( - host: String, port: Int, proxy: SSHTransportProxy?, - jumps: [SSHRouteHopConfiguration], timeout: TimeInterval, - progress: SSHRouteProgressHandler? = nil - ) throws -> SSHRoutedSocket { - guard !jumps.isEmpty else { - return SSHRoutedSocket( - socket: try SSHSocketConnector.open( - host: host, port: port, proxy: proxy, timeout: timeout - ), - bridges: [] - ) - } - - var bridges: [SSHChannelSocketBridge] = [] - var currentSocket = try SSHSocketConnector.open( - host: jumps[0].host, port: jumps[0].port, - proxy: jumps[0].proxy, timeout: timeout - ) - do { - for index in jumps.indices { - let hop = jumps[index] - progress?(.connecting(hop.id)) - let session = try connectHop( - hop, socket: currentSocket, level: index + 1, progress: progress - ) - progress?(.connected(hop.id)) - let nextHost: String - let nextPort: Int - let nextProxy: SSHTransportProxy? - if jumps.indices.contains(index + 1) { - let next = jumps[index + 1] - nextHost = next.host - nextPort = next.port - nextProxy = next.proxy - } else { - nextHost = host - nextPort = port - nextProxy = proxy - } - - let channelHost = nextProxy?.hostname ?? nextHost - let channelPort = nextProxy?.port ?? nextPort - guard - let channel = channelHost.withCString({ target in - libssh2_channel_direct_tcpip_ex( - session, target, Int32(channelPort), "127.0.0.1", 0 - ) - }) - else { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind route failed", "" - ) - _ = libssh2_session_free(session) - Darwin.close(currentSocket) - throw TermindSSHError.route( - "Jump Host \(index + 1) (\(hop.name)) denied TCP forwarding." - ) - } - - let bridge = try SSHChannelSocketBridge( - transportSocket: currentSocket, session: session, channel: channel, - timeout: timeout - ) - bridges.append(bridge) - currentSocket = bridge.clientSocket - if let nextProxy { - do { - try SSHSocketConnector.negotiate( - socket: currentSocket, host: nextHost, port: nextPort, - proxy: nextProxy - ) - } catch { - throw TermindSSHError.route( - "Proxy after Jump Host \(index + 1) (\(hop.name)): \(error.localizedDescription)" - ) - } - } - } - return SSHRoutedSocket(socket: currentSocket, bridges: bridges) - } catch { - Darwin.close(currentSocket) - for bridge in bridges.reversed() { bridge.close() } - throw error - } - } - - func close() { - guard !isClosed else { return } - isClosed = true - Darwin.close(socket) - for bridge in bridges.reversed() { bridge.close() } - bridges.removeAll() - } - - private static func connectHop( - _ hop: SSHRouteHopConfiguration, socket: Int32, level: Int, - progress: SSHRouteProgressHandler? - ) throws -> OpaquePointer { - guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.route("Jump Host \(level) (\(hop.name)): unable to create SSH session.") - } - do { - libssh2_session_set_blocking(session, 1) - guard libssh2_session_handshake(session, socket) == 0 else { - throw SSHSessionWorker.sessionError(session, prefix: "SSH handshake failed") - } - let observed = try SSHSessionWorker.observation( - host: hop.host, port: hop.port, session: session - ) - try SSHSessionWorker.validateHostKey(observed, expected: hop.expectedHostKeys) - progress?(.authenticating(hop.id)) - try SSHSessionWorker.authenticate( - username: hop.username, credentials: hop.credentials, session: session - ) - progress?(.authenticated(hop.id)) - return session - } catch { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind route failed", "" - ) - _ = libssh2_session_free(session) - throw TermindSSHError.routeHost( - hostID: hop.id, - authentication: (error as? TermindSSHError)?.shouldRequestAuthentication == true, - serverChallenge: (error as? TermindSSHError)?.isServerAuthenticationChallenge == true, - hostKey: (error as? TermindSSHError)?.isHostKeyChanged == true, - message: "Jump Host \(level) (\(hop.name)): \(error.localizedDescription)" - ) - } - } -} - -/// Bridges a direct-tcpip channel into a normal file descriptor for the next -/// libssh2 session. All calls touching the hop's libssh2 session are confined -/// to this queue. -private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable { - let clientSocket: Int32 - - private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-bridge") - private var transportSocket: Int32 - private var pumpSocket: Int32 - private var session: OpaquePointer? - private var channel: OpaquePointer? - private var timer: (any DispatchSourceTimer)? - private var toChannel = Data() - private var toSocket = Data() - private var socketEOF = false - private var channelEOF = false - private var channelWriteClosed = false - private var isClosed = false - - init( - transportSocket: Int32, session: OpaquePointer, channel: OpaquePointer, - timeout: TimeInterval - ) throws { - var pair: [Int32] = [-1, -1] - guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else { - _ = libssh2_channel_free(channel) - _ = libssh2_session_free(session) - Darwin.close(transportSocket) - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - self.transportSocket = transportSocket - self.session = session - self.channel = channel - pumpSocket = pair[0] - clientSocket = pair[1] - - var yes: Int32 = 1 - for descriptor in pair { - setsockopt( - descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes, - socklen_t(MemoryLayout.size) - ) - } - var interval = timeval(tv_sec: Int(timeout), tv_usec: 0) - setsockopt( - clientSocket, SOL_SOCKET, SO_RCVTIMEO, &interval, - socklen_t(MemoryLayout.size) - ) - setsockopt( - clientSocket, SOL_SOCKET, SO_SNDTIMEO, &interval, - socklen_t(MemoryLayout.size) - ) - let flags = fcntl(pumpSocket, F_GETFL, 0) - if flags >= 0 { _ = fcntl(pumpSocket, F_SETFL, flags | O_NONBLOCK) } - libssh2_session_set_blocking(session, 0) - - let source = DispatchSource.makeTimerSource(queue: queue) - source.schedule(deadline: .now(), repeating: .milliseconds(2), leeway: .milliseconds(1)) - source.setEventHandler { [weak self] in self?.pump() } - timer = source - source.resume() - } - - func close() { - queue.sync { closeOnQueue() } - } - - private func pump() { - guard !isClosed, let session, let channel else { return } - let highWaterMark = 2 * 1024 * 1024 - - if !socketEOF, toChannel.count < highWaterMark { - var bytes = [UInt8](repeating: 0, count: 32 * 1024) - let count = Darwin.recv(pumpSocket, &bytes, bytes.count, 0) - if count > 0 { - toChannel.append(contentsOf: bytes.prefix(count)) - } else if count == 0 { - socketEOF = true - } else if errno != EAGAIN && errno != EWOULDBLOCK { - closeOnQueue() - return - } - } - - while !toChannel.isEmpty { - let written = toChannel.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } - return libssh2_channel_write_ex(channel, 0, base, bytes.count) - } - if written > 0 { - toChannel.removeFirst(written) - } else if written == LIBSSH2_ERROR_EAGAIN { - break - } else { - closeOnQueue() - return - } - } - if socketEOF, toChannel.isEmpty, !channelWriteClosed { - let result = libssh2_channel_send_eof(channel) - if result == 0 { - channelWriteClosed = true - } else if result != LIBSSH2_ERROR_EAGAIN { - closeOnQueue() - return - } - } - - if toSocket.count < highWaterMark { - var bytes = [CChar](repeating: 0, count: 32 * 1024) - while toSocket.count < highWaterMark { - let count = libssh2_channel_read_ex(channel, 0, &bytes, bytes.count) - if count > 0 { - toSocket.append(Data(bytes: bytes, count: count)) - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - break - } else { - closeOnQueue() - return - } - } - } - if libssh2_channel_eof(channel) == 1 { channelEOF = true } - - while !toSocket.isEmpty { - let sent = toSocket.withUnsafeBytes { bytes -> Int in - guard let base = bytes.baseAddress else { return 0 } - return Darwin.send(pumpSocket, base, bytes.count, 0) - } - if sent > 0 { - toSocket.removeFirst(sent) - } else if errno == EAGAIN || errno == EWOULDBLOCK { - break - } else { - closeOnQueue() - return - } - } - if channelEOF && toSocket.isEmpty { - _ = Darwin.shutdown(pumpSocket, SHUT_WR) - } - var next = Int32(0) - _ = libssh2_keepalive_send(session, &next) - } - - private func closeOnQueue() { - guard !isClosed else { return } - isClosed = true - timer?.cancel() - timer = nil - if pumpSocket >= 0 { - Darwin.close(pumpSocket) - pumpSocket = -1 - } - if let channel { - _ = libssh2_channel_close(channel) - _ = libssh2_channel_free(channel) - self.channel = nil - } - if let session { - _ = libssh2_session_disconnect_ex( - session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the jump route", "" - ) - _ = libssh2_session_free(session) - self.session = nil - } - if transportSocket >= 0 { - Darwin.close(transportSocket) - transportSocket = -1 - } - } -} +typealias SSHRoutedSocket = TermindRemoteInfrastructure.SSHRoutedSocket diff --git a/TFM/Infrastructure/SSH/SSHSessionActor.swift b/TFM/Infrastructure/SSH/SSHSessionActor.swift index c0638619..f30862f4 100644 --- a/TFM/Infrastructure/SSH/SSHSessionActor.swift +++ b/TFM/Infrastructure/SSH/SSHSessionActor.swift @@ -1,1344 +1,8 @@ -import CSSH2 -import Darwin -import Foundation -import TermindDomain - -import struct TermindDomain.Host - -/// Installs a small cwd-reporting hook before remote output reaches Ghostty. -/// The probe, its echo, and the installation command stay inside this state -/// machine; only the login banner and the final integrated prompt are emitted. -nonisolated struct RemoteShellIntegrationBootstrap: Sendable { - nonisolated struct Update: Sendable { - var command: Data? - var visibleOutput: Data? - var isComplete = false - var integrationInstalled = false - var shell: String? - var systemInfo: RemoteSystemInfo? - var homeDirectory: String? - var homeResolutionUnavailable = false - } - - private enum Phase: Sendable { - case probing - case scanningForLateProbe - case suppressingLateProbe - case installing - case complete - } - - private static let responseTimeout: UInt64 = 3_000_000_000 - private static let quietTimeout: UInt64 = 500_000_000 - - let token: String - let startCommand: Data - private var deadline: UInt64 - private var phase: Phase = .probing - private var buffer = Data() - private var preservedOutput = Data() - private var lastActivity: UInt64? - private var userDidWrite = false - private var installationStartedLate = false - private var detectedShell: String? - private var detectedSystemInfo: RemoteSystemInfo? - - init( - token: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""), - now: UInt64 = DispatchTime.now().uptimeNanoseconds - ) { - self.token = token - // The POSIX helper reads os-release as inert assignment text instead of - // sourcing it. Its output precedes the shell marker, so a missing `sh` - // still leaves the original shell-only bootstrap path usable. - startCommand = Data( - (" command sh -c 'f=; did=; dlike=; dver=; " - + "[ -r /etc/os-release ] && f=/etc/os-release; " - + "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; " - + "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in " - + "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; " - + "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; " - + "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); " - + "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); " - + "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" " - + "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; " - + "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n").utf8 - ) - deadline = now + Self.responseTimeout - } - - mutating func noteUserInput() { - userDidWrite = true - } - - mutating func receive( - _ data: Data, now: UInt64 = DispatchTime.now().uptimeNanoseconds - ) -> Update { - guard phase != .complete else { - return Update(visibleOutput: data, isComplete: true) - } - buffer.append(data) - switch phase { - case .probing: - return receiveProbe(now: now, isLate: false) - case .scanningForLateProbe: - return scanForLateProbe(now: now) - case .suppressingLateProbe: - lastActivity = now - return receiveProbe(now: now, isLate: true) - case .installing: - return receiveInstallation() - case .complete: - return Update(visibleOutput: data, isComplete: true) - } - } - - /// Advances time-dependent bootstrap work. The first probe timeout restores - /// the terminal but deliberately keeps a streaming signature scanner alive: - /// a slow remote may echo the probe only after the UI is interactive again. - mutating func poll( - now: UInt64 = DispatchTime.now().uptimeNanoseconds - ) -> Update { - switch phase { - case .probing where now >= deadline: - let visible = cleanBufferedOutput() - phase = .scanningForLateProbe - lastActivity = nil - return Update(visibleOutput: visible, homeResolutionUnavailable: true) - case .scanningForLateProbe: - guard let lastActivity, now >= lastActivity + Self.quietTimeout, - !buffer.isEmpty - else { return Update() } - let visible = buffer - buffer.removeAll(keepingCapacity: false) - self.lastActivity = nil - return Update(visibleOutput: visible) - case .suppressingLateProbe: - guard - now >= deadline - || lastActivity.map({ now >= $0 + Self.quietTimeout }) == true - else { return Update() } - let visible = outputAfterLastLineBreak(in: buffer) - finish() - return Update(visibleOutput: visible, isComplete: true) - case .installing where now >= deadline: - let visible = - installationStartedLate - ? outputAfterLastLineBreak(in: buffer) - : cleanBufferedOutput() - finish() - return Update(visibleOutput: visible, isComplete: true) - case .probing, .installing, .complete: - return Update() - } - } - - private mutating func receiveProbe(now: UInt64, isLate: Bool) -> Update { - let prefix = Data("__TRMD_\(token)_SHELL__".utf8) - guard let prefixRange = buffer.range(of: prefix) else { return Update() } - let shellStart = prefixRange.upperBound - let terminator = Data("__".utf8) - guard - let terminatorRange = buffer.range( - of: terminator, in: shellStart.. Update { - let marker = Data("__TRMD_\(token)_DONE__".utf8) - guard let markerRange = buffer.range(of: marker), - let lineEnd = buffer[markerRange.upperBound...].firstIndex(of: 0x0A) - else { return Update() } - - var visible = preservedOutput - visible.append(Data(buffer[buffer.index(after: lineEnd)...])) - let homeDirectory = parseHomeDirectory(in: buffer) - finish() - return Update( - visibleOutput: visible, isComplete: true, - integrationInstalled: true, shell: detectedShell, - systemInfo: detectedSystemInfo, - homeDirectory: homeDirectory - ) - } - - private func parseHomeDirectory(in data: Data) -> String? { - let prefix = Data("__TRMD_\(token)_HOME__".utf8) - guard let prefixRange = data.range(of: prefix), - let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) - else { return nil } - var payload = Data(data[prefixRange.upperBound.. RemoteSystemInfo? { - let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) - guard let prefixRange = data.range(of: prefix), - let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) - else { return nil } - - var payload = Data(data[prefixRange.upperBound..= 2, payload.suffix(2).elementsEqual(Data("__".utf8)) - else { return nil } - payload.removeLast(2) - return RemoteSystemInfo(probeFields: String(decoding: payload, as: UTF8.self)) - } - - private func removingSystemInfoMarker(from data: Data) -> Data { - let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) - guard let prefixRange = data.range(of: prefix), - let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) - else { return data } - var result = Data(data[.. Update { - let echo = Data(startCommand.dropFirst().dropLast()) - let marker = Data("__TRMD_\(token)_SHELL__".utf8) - let targets = [echo, marker] - - if let match = earliestMatch(of: targets, in: buffer) { - var suppressionStart = match.range.lowerBound - if match.targetIndex == 0, suppressionStart > buffer.startIndex { - let preceding = buffer.index(before: suppressionStart) - if buffer[preceding] == 0x20 { suppressionStart = preceding } - } - let visible = Data(buffer[.. 0, buffer.count > retainedCount { - let retainedStart = buffer.index(buffer.endIndex, offsetBy: -retainedCount) - let preceding = buffer.index(before: retainedStart) - if buffer[preceding] == 0x20 { retainedCount += 1 } - } - let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retainedCount) - let visible = Data(buffer[.. (range: Range, targetIndex: Int)? - { - targets.enumerated().compactMap { index, target in - data.range(of: target).map { (range: $0, targetIndex: index) } - }.min { lhs, rhs in lhs.range.lowerBound < rhs.range.lowerBound } - } - - private func longestSuffixMatchingPrefix(of targets: [Data], in data: Data) -> Int { - let maximum = targets.map(\.count).max() ?? 0 - guard !data.isEmpty, maximum > 1 else { return 0 } - for count in stride(from: min(data.count, maximum - 1), through: 1, by: -1) { - if targets.contains(where: { target in - target.count >= count && data.suffix(count).elementsEqual(target.prefix(count)) - }) { - return count - } - } - return 0 - } - - private func mergingVisible(_ visible: Data, into update: Update) -> Update { - guard !visible.isEmpty else { return update } - var combined = visible - if let following = update.visibleOutput { combined.append(following) } - return Update( - command: update.command, visibleOutput: combined, - isComplete: update.isComplete, - integrationInstalled: update.integrationInstalled, - shell: update.shell, systemInfo: update.systemInfo, - homeDirectory: update.homeDirectory, - homeResolutionUnavailable: update.homeResolutionUnavailable - ) - } - - private mutating func cleanBufferedOutput() -> Data { - var visible = preservedOutput - if visible.isEmpty { visible = outputBeforeCommand(in: buffer) } - let suffix = outputAfterLastLineBreak(in: buffer) - if !suffix.isEmpty { visible.append(suffix) } - buffer.removeAll(keepingCapacity: false) - preservedOutput.removeAll(keepingCapacity: false) - return visible - } - - private func outputAfterLastLineBreak(in data: Data) -> Data { - guard let lineEnd = data.lastIndex(of: 0x0A), - data.index(after: lineEnd) < data.endIndex - else { return Data() } - return Data(data[data.index(after: lineEnd)...]) - } - - private mutating func finish() { - phase = .complete - buffer.removeAll(keepingCapacity: false) - preservedOutput.removeAll(keepingCapacity: false) - lastActivity = nil - } - - private func outputBeforeCommand(in data: Data) -> Data { - let echo = Data(startCommand.dropLast()) - let boundary = data.range(of: echo)?.lowerBound ?? data.endIndex - let prefix = data[.. Data? { - let marker = - "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'" - let command: String - switch shell { - case "bash": - command = - " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then __termind_integration_token=\"\(token)\"; __termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; __termind_report_cwd() { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; __termind_report_prompt() { builtin local status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"; builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; __termind_command_ready=1; return \"$status\"; }; __termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"; __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; }; __termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; }; __termind_compose_draft() { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"; }; __termind_compose_clear() { READLINE_LINE=; READLINE_POINT=0; }; for __termind_map in emacs-standard vi-insertion vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done; builtin unset __termind_map; if [[ -n \"${bash_preexec_imported:-}\" ]]; then [[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command); [[ \" ${precmd_functions[*]} \" == *\" __termind_report_prompt \"* ]] || precmd_functions=(__termind_report_prompt \"${precmd_functions[@]}\"); else __termind_debug_trap=\"$(__termind_get_trap DEBUG)\"; if [[ \"$__termind_debug_trap\" != *\"__termind_report_command\"* ]]; then if [[ -n \"$__termind_debug_trap\" ]]; then builtin trap \"$__termind_debug_trap\"$'\\n''__termind_report_command' DEBUG; else builtin trap '__termind_report_command' DEBUG; fi; fi; if [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then [[ \" ${PROMPT_COMMAND[*]} \" == *\" __termind_report_prompt \"* ]] || PROMPT_COMMAND=(__termind_report_prompt \"${PROMPT_COMMAND[@]}\"); elif [[ \"${PROMPT_COMMAND:-}\" != __termind_report_prompt* ]]; then PROMPT_COMMAND=\"__termind_report_prompt${PROMPT_COMMAND:+; $PROMPT_COMMAND}\"; fi; fi; fi; \(marker); __termind_report_prompt\n" - case "zsh": - // Unlike bash, zsh reserves `status` as a read-only special parameter. - // Assigning it aborts this function and the remaining prompt hooks. - command = - " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n" - case "fish": - command = - " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n" - default: - return nil - } - return Data(command.utf8) - } -} - -/// Removes authenticated command-report OSC sequences before output reaches -/// Ghostty and emits their decoded payloads separately. The session token makes -/// ordinary OSC output in a remote program ineligible to forge local history. -nonisolated struct RemoteCommandEventFilter: Sendable { - static let maximumPayloadBytes = 64 * 1024 - - nonisolated enum Event: Sendable, Equatable { - case commandStarted(String) - case commandFinished(Int32, duration: TimeInterval?) - case promptReady - case draft(String) - } - - nonisolated struct Update: Sendable { - var visibleOutput = Data() - var events: [Event] = [] - } - - private let commandPrefix: Data - private let commandFinishedPrefix: Data - private let promptPrefix: Data - private let draftPrefix: Data - private var buffer = Data() - - init(token: String) { - commandPrefix = Data("\u{1B}]777;TermindCommand=\(token);".utf8) - commandFinishedPrefix = Data("\u{1B}]777;TermindCommandFinished=\(token);".utf8) - promptPrefix = Data("\u{1B}]777;TermindPrompt=\(token)".utf8) - draftPrefix = Data("\u{1B}]777;TermindDraft=\(token);".utf8) - } - - mutating func receive(_ data: Data) -> Update { - buffer.append(data) - var update = Update() - while let match = earliestMarker() { - let marker = match.range - if marker.lowerBound > buffer.startIndex { - update.visibleOutput.append(buffer[.. Self.maximumPayloadBytes - { - // A forged or truncated authenticated marker must not retain - // the terminal stream forever. Drop the malformed frame and - // resume with the next received bytes. - buffer.removeAll(keepingCapacity: false) - } else { - buffer = Data(buffer[marker.lowerBound...]) - } - return update - } - let payload = Data(buffer[marker.upperBound.. Int { - let prefixes = [commandPrefix, commandFinishedPrefix, promptPrefix, draftPrefix] - guard !buffer.isEmpty else { return 0 } - let maximum = prefixes.map(\.count).max() ?? 0 - guard maximum > 1 else { return 0 } - for count in stride(from: min(buffer.count, maximum - 1), through: 1, by: -1) { - if prefixes.contains(where: { - $0.count >= count && buffer.suffix(count).elementsEqual($0.prefix(count)) - }) { - return count - } - } - return 0 - } - - private enum MarkerKind { case command, commandFinished, prompt, draft } - - private func earliestMarker() -> (range: Range, kind: MarkerKind)? { - let candidates: [(Range, MarkerKind)] = [ - buffer.range(of: commandPrefix).map { ($0, .command) }, - buffer.range(of: commandFinishedPrefix).map { ($0, .commandFinished) }, - buffer.range(of: promptPrefix).map { ($0, .prompt) }, - buffer.range(of: draftPrefix).map { ($0, .draft) }, - ].compactMap { $0 } - return candidates.min { $0.0.lowerBound < $1.0.lowerBound } - } - - static func decodePercentEncoded(_ data: Data) -> String? { - let bytes = Array(data) - var decoded: [UInt8] = [] - decoded.reserveCapacity(bytes.count) - var index = 0 - while index < bytes.count { - if bytes[index] == 0x25, index + 2 < bytes.count, - let high = hex(bytes[index + 1]), let low = hex(bytes[index + 2]) - { - decoded.append(high << 4 | low) - index += 3 - } else { - decoded.append(bytes[index]) - index += 1 - } - } - return String(data: Data(decoded), encoding: .utf8) - } - - private static func hex(_ byte: UInt8) -> UInt8? { - switch byte { - case 0x30...0x39: byte - 0x30 - case 0x41...0x46: byte - 0x41 + 10 - case 0x61...0x66: byte - 0x61 + 10 - default: nil - } - } - - private static func duration(milliseconds text: Substring) -> TimeInterval? { - guard let milliseconds = UInt64(text), milliseconds <= 31_536_000_000 else { - return nil - } - return Double(milliseconds) / 1_000 - } -} - -nonisolated enum SSHShellIntegrationEvent: Sendable { - case available(shell: String, systemInfo: RemoteSystemInfo?, homeDirectory: String?) - case systemInfo(RemoteSystemInfo) - case homeResolutionUnavailable - case promptReady - case commandStarted(String) - case commandFinished(Int32, duration: TimeInterval?) - case draft(String) -} - -actor SSHSessionActor { - typealias OutputHandler = @Sendable (Data) -> Void - typealias ExitHandler = @Sendable (Result) -> Void - typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void - - private let worker: SSHSessionWorker - - init( - output: @escaping OutputHandler, exit: @escaping ExitHandler, - shellIntegration: @escaping ShellIntegrationHandler = { _ in } - ) { - worker = SSHSessionWorker( - output: output, exit: exit, shellIntegrationHandler: shellIntegration - ) - } - - static func discoverHostKey( - hostID: Host.ID, host: String, port: Int, proxy: SSHTransportProxy? = nil, - jumps: [SSHRouteHopConfiguration] = [], - timeout: TimeInterval = 15, - progress: SSHRouteProgressHandler? = nil - ) async throws - -> SSHHostKeyObservation - { - try await SSHSessionWorker.discoverHostKey( - hostID: hostID, host: host, port: port, proxy: proxy, jumps: jumps, - timeout: timeout, progress: progress - ) - } - - /// Appends a named public key to a remote account's `authorized_keys`, unless - /// the exact key line is already present. The generated comment and key travel - /// over the channel's stdin, never as shell syntax. - static func installAuthorizedKey( - _ publicKey: String, named keyName: String, - using configuration: SSHConnectionConfiguration - ) async throws { - try await SSHSessionWorker.installAuthorizedKey( - publicKey, named: keyName, using: configuration - ) - } - - func connect( - hostID: Host.ID, - host: String, - port: Int, - username: String, - credentials: [SSHCredentials], - expectedHostKeys: Set, - proxy: SSHTransportProxy? = nil, - jumps: [SSHRouteHopConfiguration] = [], - shellIntegrationEnabled: Bool = true, - columns: UInt16, - rows: UInt16, - progress: SSHRouteProgressHandler? = nil - ) async throws { - try await worker.connect( - hostID: hostID, host: host, port: port, username: username, - credentials: credentials, expectedHostKeys: expectedHostKeys, - proxy: proxy, jumps: jumps, - shellIntegrationEnabled: shellIntegrationEnabled, - columns: columns, rows: rows, progress: progress - ) - } - - func write(_ data: Data) { - worker.write(data) - } - - func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { - worker.resize( - columns: columns, rows: rows, - widthPixels: widthPixels, heightPixels: heightPixels - ) - } - - func disconnect() { - worker.disconnect() - } -} - -nonisolated final class SSHSessionWorker: @unchecked Sendable { - private static let initialization: Void = { - precondition(libssh2_init(0) == 0, "libssh2 initialization failed") - }() - - private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.session") - private let output: SSHSessionActor.OutputHandler - private let exit: SSHSessionActor.ExitHandler - private let shellIntegrationHandler: SSHSessionActor.ShellIntegrationHandler - - private var socket: Int32 = -1 - private var routeTransport: SSHRoutedSocket? - private var session: OpaquePointer? - private var channel: OpaquePointer? - private var pumpTimer: (any DispatchSourceTimer)? - private var pendingWrites = Data() - private var shellIntegration: RemoteShellIntegrationBootstrap? - private var commandEventFilter: RemoteCommandEventFilter? - private var didReportExit = false - - init( - output: @escaping SSHSessionActor.OutputHandler, - exit: @escaping SSHSessionActor.ExitHandler, - shellIntegrationHandler: @escaping SSHSessionActor.ShellIntegrationHandler - ) { - _ = Self.initialization - self.output = output - self.exit = exit - self.shellIntegrationHandler = shellIntegrationHandler - } - - static func discoverHostKey( - hostID: Host.ID, host: String, port: Int, proxy: SSHTransportProxy?, - jumps: [SSHRouteHopConfiguration], timeout: TimeInterval, - progress: SSHRouteProgressHandler? - ) async throws - -> SSHHostKeyObservation - { - _ = initialization - return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - var transport: SSHRoutedSocket? - var session: OpaquePointer? - do { - transport = try SSHRoutedSocket.open( - host: host, port: port, proxy: proxy, - jumps: jumps, timeout: timeout, progress: progress - ) - progress?(.connecting(hostID)) - guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library("Unable to create an SSH session.") - } - session = created - libssh2_session_set_blocking(created, 1) - guard let transport, - libssh2_session_handshake(created, transport.socket) == 0 - else { - throw sessionError(created, prefix: "SSH handshake failed") - } - continuation.resume(returning: try observation(host: host, port: port, session: created)) - } catch { - continuation.resume(throwing: error) - } - if let session { - _ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "probe complete", "") - _ = libssh2_session_free(session) - } - transport?.close() - } - } - } - - /// Opens a transport, handshakes, validates the host key, and authenticates — - /// the identical prologue every non-PTY entry point needs before it can open - /// a channel. On any failure it frees what it created and rethrows, so a - /// caller only owns the pair once it has been handed one. - /// - /// The returned session is left in blocking mode. Callers that want the - /// non-blocking pump switch it themselves once their channel is running. - static func openAuthenticatedSession( - using configuration: SSHConnectionConfiguration, - timeout: TimeInterval, - progress: SSHRouteProgressHandler? = nil - ) throws -> (transport: SSHRoutedSocket, session: OpaquePointer) { - _ = initialization - var transport: SSHRoutedSocket? - var session: OpaquePointer? - do { - let opened = try SSHRoutedSocket.open( - host: configuration.host, port: configuration.port, - proxy: configuration.proxy, jumps: configuration.jumps, - timeout: timeout, progress: progress - ) - transport = opened - progress?(.connecting(configuration.hostID)) - guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library("Unable to create an SSH session.") - } - session = created - libssh2_session_set_blocking(created, 1) - guard libssh2_session_handshake(created, opened.socket) == 0 else { - throw sessionError(created, prefix: "SSH handshake failed") - } - let observed = try observation( - host: configuration.host, port: configuration.port, session: created - ) - try validateHostKey(observed, expected: configuration.expectedHostKeys) - progress?(.authenticating(configuration.hostID)) - try authenticate( - username: configuration.username, - credentials: configuration.credentials, - session: created - ) - progress?(.authenticated(configuration.hostID)) - return (opened, created) - } catch { - if let session { _ = libssh2_session_free(session) } - transport?.close() - throw error - } - } - - static func installAuthorizedKey( - _ publicKey: String, named keyName: String, - using configuration: SSHConnectionConfiguration - ) async throws { - _ = initialization - let input = try authorizedKeyInstallInput(publicKey: publicKey, keyName: keyName) - - try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .userInitiated).async { - do { - try installAuthorizedKeyBlocking( - input, using: configuration - ) - continuation.resume() - } catch { - continuation.resume(throwing: error) - } - } - } - } - - /// A short-lived exec channel rather than a PTY. The fixed script creates - /// the OpenSSH directory with restrictive permissions and performs an exact - /// line check before appending, making repeated exports idempotent. - private static func installAuthorizedKeyBlocking( - _ input: Data, using configuration: SSHConnectionConfiguration - ) throws { - let (transport, created) = try openAuthenticatedSession( - using: configuration, timeout: 20 - ) - var channel: OpaquePointer? - defer { - if let channel { - _ = libssh2_channel_send_eof(channel) - _ = libssh2_channel_close(channel) - _ = libssh2_channel_free(channel) - } - _ = libssh2_session_disconnect_ex( - created, SSH_DISCONNECT_BY_APPLICATION, "Termind completed key export", "" - ) - _ = libssh2_session_free(created) - transport.close() - } - - guard - let opened = libssh2_channel_open_ex( - created, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0 - ) - else { - throw sessionError(created, prefix: "Unable to open SSH channel") - } - channel = opened - - let command = - #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"# - let started = command.withCString { commandBuffer in - libssh2_channel_process_startup( - opened, "exec", 4, commandBuffer, UInt32(command.utf8.count) - ) - } - guard started == 0 else { - throw sessionError(created, prefix: "Unable to start authorized_keys update") - } - - try input.withUnsafeBytes { bytes in - guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return } - var offset = 0 - while offset < bytes.count { - let written = libssh2_channel_write_ex( - opened, 0, base.advanced(by: offset), bytes.count - offset - ) - guard written > 0 else { - throw sessionError(created, prefix: "Unable to send the public key") - } - offset += written - } - } - guard libssh2_channel_send_eof(opened) == 0 else { - throw sessionError(created, prefix: "Unable to finish the public-key upload") - } - _ = libssh2_channel_wait_eof(opened) - - let standardError = readAll(from: opened, stream: 1) - let status = libssh2_channel_get_exit_status(opened) - guard status == 0 else { - let detail = standardError.trimmingCharacters(in: .whitespacesAndNewlines) - throw TermindSSHError.library( - detail.isEmpty - ? "The remote authorized_keys update exited with status \(status)." - : "The remote authorized_keys update failed: \(detail)" - ) - } - } - - static func authorizedKeyInstallInput(publicKey: String, keyName: String) throws -> Data { - let normalizedKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) - guard - !normalizedKey.isEmpty, !normalizedKey.contains("\n"), - !normalizedKey.contains("\r") - else { - throw TermindSSHError.library("The public key is not a single OpenSSH key line.") - } - - let printableName = keyName.unicodeScalars - .map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) } - .joined() - let normalizedName = - printableName - .split(whereSeparator: \Character.isWhitespace) - .joined(separator: " ") - let safeName = normalizedName.isEmpty ? "Key" : normalizedName - return Data(("# \(safeName) Key by Termind\n\(normalizedKey)\n").utf8) - } - - private static func readAll(from channel: OpaquePointer, stream: Int32) -> String { - var data = Data() - var buffer = [CChar](repeating: 0, count: 4 * 1024) - while true { - let count = libssh2_channel_read_ex(channel, stream, &buffer, buffer.count) - guard count > 0 else { break } - data.append(Data(bytes: buffer, count: count)) - } - return String(decoding: data, as: UTF8.self) - } - - func connect( - hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials], - expectedHostKeys: Set, proxy: SSHTransportProxy?, - jumps: [SSHRouteHopConfiguration], - shellIntegrationEnabled: Bool, - columns: UInt16, rows: UInt16, - progress: SSHRouteProgressHandler? - ) async throws { - try await withCheckedThrowingContinuation { continuation in - queue.async { [self] in - do { - cleanup() - routeTransport = try SSHRoutedSocket.open( - host: host, port: port, proxy: proxy, - jumps: jumps, timeout: 20, progress: progress - ) - progress?(.connecting(hostID)) - guard let routeTransport else { throw TermindSSHError.disconnected } - socket = routeTransport.socket - guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { - throw TermindSSHError.library("Unable to create an SSH session.") - } - session = created - libssh2_session_set_blocking(created, 1) - guard libssh2_session_handshake(created, socket) == 0 else { - throw Self.sessionError(created, prefix: "SSH handshake failed") - } - let observed = try Self.observation(host: host, port: port, session: created) - try Self.validateHostKey(observed, expected: expectedHostKeys) - progress?(.authenticating(hostID)) - try Self.authenticate(username: username, credentials: credentials, session: created) - progress?(.authenticated(hostID)) - guard - let opened = libssh2_channel_open_ex( - created, "session", 7, - 2 * 1024 * 1024, - 32 * 1024, nil, 0 - ) - else { - throw Self.sessionError(created, prefix: "Unable to open SSH channel") - } - channel = opened - let pty = libssh2_channel_request_pty_ex( - opened, "xterm-256color", 14, nil, 0, - Int32(columns), Int32(rows), 0, 0 - ) - guard pty == 0 else { throw Self.sessionError(created, prefix: "PTY request failed") } - guard libssh2_channel_process_startup(opened, "shell", 5, nil, 0) == 0 else { - throw Self.sessionError(created, prefix: "Remote shell request failed") - } - libssh2_session_set_blocking(created, 0) - libssh2_keepalive_config(created, 1, 20) - didReportExit = false - if shellIntegrationEnabled { - let bootstrap = RemoteShellIntegrationBootstrap() - pendingWrites.append(bootstrap.startCommand) - commandEventFilter = RemoteCommandEventFilter(token: bootstrap.token) - shellIntegration = bootstrap - } - startPump() - progress?(.connected(hostID)) - continuation.resume() - } catch { - cleanup() - continuation.resume(throwing: error) - } - } - } - } - - func write(_ data: Data) { - guard !data.isEmpty else { return } - queue.async { [self] in - if var integration = shellIntegration { - integration.noteUserInput() - shellIntegration = integration - } - pendingWrites.append(data) - pump() - } - } - - func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { - guard columns > 0, rows > 0 else { return } - queue.async { [self] in - guard let channel else { return } - _ = libssh2_channel_request_pty_size_ex( - channel, Int32(columns), Int32(rows), - Int32(clamping: widthPixels), Int32(clamping: heightPixels) - ) - } - } - - func disconnect() { - queue.async { [self] in - cleanup() - } - } - - static func authenticate( - username: String, credentials: [SSHCredentials], session: OpaquePointer - ) throws { - let methods = try authenticationMethods(username: username, session: session) - // Asking for the method list sends SSH's `none` authentication request. - // Servers that need no credential accept it there and report the - // session as authenticated without another userauth call. - if libssh2_userauth_authenticated(session) == 1 { return } - var attempted = false - var secureEnclaveError: (any Error)? - for credentials in credentials { - switch credentials { - case .password(let password): - if methods.contains("password") { - attempted = true - let result = username.withCString { user in - password.withCString { password in - libssh2_userauth_password_ex( - session, user, UInt32(username.utf8.count), - password, UInt32(strlen(password)), nil - ) - } - } - if result == 0 { return } - } - if methods.contains("keyboard-interactive") { - attempted = true - if keyboardInteractive( - username: username, password: password, session: session - ) == 0 { - return - } - } - case .privateKey(let privateKey, let publicKey, let passphrase): - guard methods.contains("publickey") else { continue } - attempted = true - let result = username.withCString { user in - privateKey.withCString { privateBuffer in - let call: (UnsafePointer?) -> Int32 = { publicBuffer in - if let passphrase { - return passphrase.withCString { passphraseBuffer in - libssh2_userauth_publickey_frommemory( - session, user, username.utf8.count, - publicBuffer, publicKey?.utf8.count ?? 0, - privateBuffer, privateKey.utf8.count, passphraseBuffer - ) - } - } - return libssh2_userauth_publickey_frommemory( - session, user, username.utf8.count, - publicBuffer, publicKey?.utf8.count ?? 0, - privateBuffer, privateKey.utf8.count, nil - ) - } - return publicKey?.withCString(call) ?? call(nil) - } - } - if result == 0 { return } - case .secureEnclave(let credential, let publicKey): - guard methods.contains("publickey") else { continue } - guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else { - secureEnclaveError = TermindSSHError.library( - "The Secure Enclave key has an invalid SSH public key." - ) - continue - } - attempted = true - let signer = SecureEnclaveSigner(credential: credential) - var abstract: UnsafeMutableRawPointer? = Unmanaged.passUnretained(signer).toOpaque() - let result = username.withCString { user in - publicBlob.withUnsafeBytes { bytes in - libssh2_userauth_publickey( - session, - user, - bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), - bytes.count, - secureEnclaveSignCallback, - &abstract - ) - } - } - if result == 0 { return } - secureEnclaveError = signer.error - } - } - if let secureEnclaveError { - throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription) - } - let available = methods.sorted().joined(separator: ", ") - let prefix = - attempted - ? "SSH authentication failed" - : "No configured credential matches the server methods (\(available))" - throw TermindSSHError.authenticationFailed( - Self.sessionErrorMessage(session, prefix: prefix) - ) - } - - private static func authenticationMethods( - username: String, session: OpaquePointer - ) throws -> Set { - let pointer = username.withCString { user in - libssh2_userauth_list(session, user, UInt32(username.utf8.count)) - } - guard let pointer else { - if libssh2_userauth_authenticated(session) == 1 { return [] } - throw Self.sessionError( - session, prefix: "Unable to read SSH authentication methods" - ) - } - let bytes = UnsafeBufferPointer(start: pointer, count: strlen(pointer)) - .map { UInt8(bitPattern: $0) } - return Set(String(decoding: bytes, as: UTF8.self).split(separator: ",").map(String.init)) - } - - private static func keyboardInteractive( - username: String, password: String, session: OpaquePointer - ) -> Int32 { - let box = Unmanaged.passRetained(SSHKeyboardPassword(password)) - let abstract = libssh2_session_abstract(session) - abstract?.pointee = box.toOpaque() - defer { - abstract?.pointee = nil - box.release() - } - return username.withCString { user in - libssh2_userauth_keyboard_interactive_ex( - session, user, UInt32(username.utf8.count), sshKeyboardInteractiveResponse - ) - } - } - - private func startPump() { - pumpTimer?.cancel() - let timer = DispatchSource.makeTimerSource(queue: queue) - timer.schedule(deadline: .now(), repeating: .milliseconds(12), leeway: .milliseconds(4)) - timer.setEventHandler { [weak self] in self?.pump() } - pumpTimer = timer - timer.resume() - } - - private func pump() { - guard let session, let channel else { return } - var buffer = [CChar](repeating: 0, count: 32 * 1024) - while true { - let count = libssh2_channel_read_ex(channel, 0, &buffer, buffer.count) - if count > 0 { - deliverOutput(Data(bytes: buffer, count: count)) - } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { - break - } else { - reportExit(.failure(Self.sessionError(session, prefix: "SSH read failed"))) - cleanup() - return - } - } - - if var integration = shellIntegration { - let update = integration.poll() - shellIntegration = update.isComplete ? nil : integration - applyShellIntegrationUpdate(update) - } - - while !pendingWrites.isEmpty { - let written = pendingWrites.withUnsafeBytes { bytes -> Int in - guard let address = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } - return libssh2_channel_write_ex(channel, 0, address, bytes.count) - } - if written > 0 { - pendingWrites.removeFirst(written) - } else if written == LIBSSH2_ERROR_EAGAIN { - break - } else { - reportExit(.failure(Self.sessionError(session, prefix: "SSH write failed"))) - cleanup() - return - } - } - - var next = Int32(0) - let keepalive = libssh2_keepalive_send(session, &next) - if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { - reportExit(.failure(Self.sessionError(session, prefix: "SSH keepalive failed"))) - cleanup() - return - } - if libssh2_channel_eof(channel) == 1 { - let status = libssh2_channel_get_exit_status(channel) - reportExit(.success(status)) - cleanup() - } - } - - private func reportExit(_ result: Result) { - guard !didReportExit else { return } - didReportExit = true - exit(result) - } - - private func deliverOutput(_ data: Data) { - guard var integration = shellIntegration else { - deliverIntegratedOutput(data) - return - } - let update = integration.receive(data) - shellIntegration = update.isComplete ? nil : integration - applyShellIntegrationUpdate(update) - } - - private func applyShellIntegrationUpdate( - _ update: RemoteShellIntegrationBootstrap.Update - ) { - if let command = update.command { - pendingWrites.append(command) - } - if update.integrationInstalled { - shellIntegrationHandler( - .available( - shell: update.shell ?? "unknown", systemInfo: update.systemInfo, - homeDirectory: update.homeDirectory - ) - ) - } else { - if let systemInfo = update.systemInfo { - shellIntegrationHandler(.systemInfo(systemInfo)) - } - if update.homeResolutionUnavailable || update.isComplete { - shellIntegrationHandler(.homeResolutionUnavailable) - } - } - if let visibleOutput = update.visibleOutput, !visibleOutput.isEmpty { - deliverIntegratedOutput(visibleOutput) - } - } - - private func deliverIntegratedOutput(_ data: Data) { - guard var commandEventFilter else { - output(data) - return - } - let update = commandEventFilter.receive(data) - self.commandEventFilter = commandEventFilter - if !update.visibleOutput.isEmpty { output(update.visibleOutput) } - for event in update.events { - switch event { - case .commandStarted(let command): - shellIntegrationHandler(.commandStarted(command)) - case .commandFinished(let status, let duration): - shellIntegrationHandler(.commandFinished(status, duration: duration)) - case .promptReady: - shellIntegrationHandler(.promptReady) - case .draft(let draft): - shellIntegrationHandler(.draft(draft)) - } - } - } - - private func cleanup() { - pumpTimer?.cancel() - pumpTimer = nil - pendingWrites.removeAll(keepingCapacity: false) - shellIntegration = nil - commandEventFilter = nil - if let channel { - _ = libssh2_channel_send_eof(channel) - _ = libssh2_channel_close(channel) - _ = libssh2_channel_free(channel) - self.channel = nil - } - if let session { - _ = libssh2_session_disconnect_ex(session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the session", "") - _ = libssh2_session_free(session) - self.session = nil - } - if socket >= 0 { - socket = -1 - } - routeTransport?.close() - routeTransport = nil - } - - static func observation(host: String, port: Int, session: OpaquePointer) throws - -> SSHHostKeyObservation - { - guard let hash = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_SHA256)) else { - throw sessionError(session, prefix: "The server did not provide a host-key fingerprint") - } - let digest = Data(bytes: hash, count: 32).base64EncodedString().replacingOccurrences(of: "=", with: "") - var length = 0 - var type = Int32(LIBSSH2_HOSTKEY_TYPE_UNKNOWN) - _ = libssh2_session_hostkey(session, &length, &type) - let algorithm: SSHKeyAlgorithm - switch type { - case LIBSSH2_HOSTKEY_TYPE_RSA: algorithm = .rsa - case LIBSSH2_HOSTKEY_TYPE_ECDSA_256: algorithm = .ecdsa256 - case LIBSSH2_HOSTKEY_TYPE_ECDSA_384: algorithm = .ecdsa384 - case LIBSSH2_HOSTKEY_TYPE_ECDSA_521: algorithm = .ecdsa521 - case LIBSSH2_HOSTKEY_TYPE_ED25519: algorithm = .ed25519 - default: - throw sessionError(session, prefix: "The server provided an unsupported host-key type") - } - return SSHHostKeyObservation( - hostname: host, port: port, algorithm: algorithm, - fingerprint: "SHA256:\(digest)" - ) - } - - static func validateHostKey( - _ observed: SSHHostKeyObservation, expected: Set - ) throws { - guard expected.contains(observed.identity) else { - throw TermindSSHError.hostKeyChanged( - expected: Set(expected.map(\.fingerprint)).sorted(), - received: observed.fingerprint - ) - } - } - - static func sessionError(_ session: OpaquePointer, prefix: String) -> TermindSSHError { - .library(sessionErrorMessage(session, prefix: prefix)) - } - - private static func sessionErrorMessage(_ session: OpaquePointer, prefix: String) -> String { - var message: UnsafeMutablePointer? - var length = Int32(0) - let code = libssh2_session_last_error(session, &message, &length, 0) - let detail = message.map { String(cString: $0) } ?? "libssh2 error \(code)" - return "\(prefix): \(detail)" - } - -} - -private nonisolated final class SecureEnclaveSigner: @unchecked Sendable { - let credential: SecureEnclaveKeyCredential - var error: (any Error)? - - init(credential: SecureEnclaveKeyCredential) { - self.credential = credential - } -} - -private nonisolated let secureEnclaveSignCallback: - @convention(c) ( - OpaquePointer?, UnsafeMutablePointer?>?, - UnsafeMutablePointer?, UnsafePointer?, Int, - UnsafeMutablePointer? - ) -> Int32 = { _, signature, signatureLength, data, dataLength, abstract in - guard let opaqueSigner = abstract?.pointee, let data, dataLength > 0 else { return -1 } - let signer = Unmanaged.fromOpaque(opaqueSigner).takeUnretainedValue() - do { - let signedData = try SecureEnclaveKey.sign( - Data(bytes: data, count: dataLength), credential: signer.credential - ) - guard let output = malloc(signedData.count)?.assumingMemoryBound(to: UInt8.self) else { - return -1 - } - signedData.copyBytes(to: output, count: signedData.count) - signature?.pointee = output - signatureLength?.pointee = signedData.count - return 0 - } catch { - signer.error = error - return -1 - } - } - -private nonisolated final class SSHKeyboardPassword: @unchecked Sendable { - let value: String - - init(_ value: String) { - self.value = value - } -} - -private nonisolated func sshKeyboardInteractiveResponse( - _ name: UnsafePointer?, _ nameLength: Int32, - _ instruction: UnsafePointer?, _ instructionLength: Int32, - _ promptCount: Int32, - _ prompts: UnsafePointer?, - _ responses: UnsafeMutablePointer?, - _ abstract: UnsafeMutablePointer? -) { - guard promptCount > 0, let responses, let opaque = abstract?.pointee else { return } - let password = Unmanaged.fromOpaque(opaque) - .takeUnretainedValue().value - for index in 0.. Int32 { - let socket = try openTCP( - host: proxy?.hostname ?? host, - port: proxy?.port ?? port, - timeout: timeout - ) - guard let proxy else { return socket } - do { - try negotiate(socket: socket, host: host, port: port, proxy: proxy) - return socket - } catch { - Darwin.close(socket) - throw error - } - } - - /// Applies proxy negotiation to an already-open byte stream. Jump routing - /// uses this with the local end of a socketpair bridged to direct-tcpip. - static func negotiate( - socket: Int32, host: String, port: Int, proxy: SSHTransportProxy - ) throws { - switch proxy.kind { - case .http: - try negotiateHTTP(socket: socket, host: host, port: port, proxy: proxy) - case .socks5: - try negotiateSOCKS5(socket: socket, host: host, port: port, proxy: proxy) - } - } - - private static func negotiateHTTP( - socket: Int32, host: String, port: Int, proxy: SSHTransportProxy - ) throws { - let authority = host.contains(":") ? "[\(host)]:\(port)" : "\(host):\(port)" - var request = "CONNECT \(authority) HTTP/1.1\r\nHost: \(authority)\r\nProxy-Connection: Keep-Alive\r\n" - if let password = proxy.password, !proxy.username.isEmpty { - let token = Data("\(proxy.username):\(password)".utf8).base64EncodedString() - request += "Proxy-Authorization: Basic \(token)\r\n" - } - request += "\r\n" - try writeAll(socket, Data(request.utf8)) - - var response = Data() - while response.range(of: Data("\r\n\r\n".utf8)) == nil { - guard response.count < 64 * 1024 else { - throw TermindSSHError.proxy("The HTTP proxy response headers are too large.") - } - // Stop exactly at the header terminator. A fast proxy may put the - // SSH banner in the same TCP packet; consuming it here would make - // the following libssh2 handshake wait for bytes already discarded. - response.append(try readSome(socket, maximum: 1)) - } - guard - let firstLine = String(decoding: response, as: UTF8.self) - .split(separator: "\n", maxSplits: 1).first - else { throw TermindSSHError.proxy("The HTTP proxy returned an empty response.") } - let fields = firstLine.split(separator: " ") - guard fields.count >= 2, let status = Int(fields[1]), (200..<300).contains(status) else { - let status = fields.count >= 2 ? String(fields[1]) : "invalid" - throw TermindSSHError.proxy("HTTP CONNECT failed with status \(status).") - } - } - - private static func negotiateSOCKS5( - socket: Int32, host: String, port: Int, proxy: SSHTransportProxy - ) throws { - let usesAuthentication = proxy.password != nil && !proxy.username.isEmpty - try writeAll(socket, Data(usesAuthentication ? [0x05, 0x01, 0x02] : [0x05, 0x01, 0x00])) - let selection = try readExactly(socket, count: 2) - guard selection[0] == 0x05, selection[1] != 0xff else { - throw TermindSSHError.proxy("The SOCKS5 proxy does not accept the configured authentication method.") - } - if selection[1] == 0x02 { - guard let password = proxy.password else { - throw TermindSSHError.proxy("The SOCKS5 proxy requires a password.") - } - let username = Data(proxy.username.utf8) - let secret = Data(password.utf8) - guard username.count <= 255, secret.count <= 255 else { - throw TermindSSHError.proxy("The SOCKS5 username or password is too long.") - } - var auth = Data([0x01, UInt8(username.count)]) - auth.append(username) - auth.append(UInt8(secret.count)) - auth.append(secret) - try writeAll(socket, auth) - let result = try readExactly(socket, count: 2) - guard result[1] == 0 else { - throw TermindSSHError.proxy("SOCKS5 proxy authentication failed.") - } - } else if selection[1] != 0x00 { - throw TermindSSHError.proxy("The SOCKS5 proxy selected an unsupported authentication method.") - } - - var request = Data([0x05, 0x01, 0x00]) - var ipv4 = in_addr() - var ipv6 = in6_addr() - if host.withCString({ inet_pton(AF_INET, $0, &ipv4) }) == 1 { - request.append(0x01) - withUnsafeBytes(of: &ipv4) { request.append(contentsOf: $0) } - } else if host.withCString({ inet_pton(AF_INET6, $0, &ipv6) }) == 1 { - request.append(0x04) - withUnsafeBytes(of: &ipv6) { request.append(contentsOf: $0) } - } else { - let hostBytes = Data(host.utf8) - guard hostBytes.count <= 255 else { - throw TermindSSHError.proxy("The proxy target hostname is too long.") - } - request.append(0x03) - request.append(UInt8(hostBytes.count)) - request.append(hostBytes) - } - request.append(UInt8((port >> 8) & 0xff)) - request.append(UInt8(port & 0xff)) - try writeAll(socket, request) - - let response = try readExactly(socket, count: 4) - guard response[0] == 0x05, response[1] == 0 else { - throw TermindSSHError.proxy("SOCKS5 CONNECT failed with reply \(response[1]).") - } - let addressLength: Int - switch response[3] { - case 0x01: addressLength = 4 - case 0x04: addressLength = 16 - case 0x03: addressLength = Int(try readExactly(socket, count: 1)[0]) - default: throw TermindSSHError.proxy("The SOCKS5 proxy returned an invalid address type.") - } - _ = try readExactly(socket, count: addressLength + 2) - } - - private static func openTCP(host: String, port: Int, timeout: TimeInterval) throws -> Int32 { - var hints = addrinfo() - hints.ai_family = AF_UNSPEC - hints.ai_socktype = SOCK_STREAM - hints.ai_protocol = IPPROTO_TCP - var result: UnsafeMutablePointer? - let status = getaddrinfo(host, String(port), &hints, &result) - guard status == 0, let first = result else { - throw TermindSSHError.socket(String(cString: gai_strerror(status))) - } - defer { freeaddrinfo(first) } - - var cursor: UnsafeMutablePointer? = first - var lastError = String(localized: "Connection failed") - while let info = cursor?.pointee { - let candidate = Darwin.socket(info.ai_family, info.ai_socktype, info.ai_protocol) - if candidate >= 0 { - var yes: Int32 = 1 - setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout.size(ofValue: yes))) - var interval = timeval(tv_sec: Int(timeout), tv_usec: 0) - setsockopt(candidate, SOL_SOCKET, SO_RCVTIMEO, &interval, socklen_t(MemoryLayout.size)) - setsockopt(candidate, SOL_SOCKET, SO_SNDTIMEO, &interval, socklen_t(MemoryLayout.size)) - do { - try connect(candidate, address: info.ai_addr, length: info.ai_addrlen, timeout: timeout) - return candidate - } catch { lastError = error.localizedDescription } - Darwin.close(candidate) - } - cursor = info.ai_next - } - throw TermindSSHError.socket(lastError) - } - - private static func connect( - _ socket: Int32, address: UnsafePointer, length: socklen_t, - timeout: TimeInterval - ) throws { - let flags = fcntl(socket, F_GETFL, 0) - guard flags >= 0, fcntl(socket, F_SETFL, flags | O_NONBLOCK) == 0 else { - throw TermindSSHError.socket(String(cString: strerror(errno))) - } - defer { _ = fcntl(socket, F_SETFL, flags) } - if Darwin.connect(socket, address, length) == 0 { return } - guard errno == EINPROGRESS else { throw TermindSSHError.socket(String(cString: strerror(errno))) } - var descriptor = pollfd(fd: socket, events: Int16(POLLOUT), revents: 0) - let result = Darwin.poll(&descriptor, 1, Int32(max(1, timeout * 1000))) - guard result > 0 else { - throw TermindSSHError.socket( - result == 0 ? String(localized: "Connection timed out.") : String(cString: strerror(errno))) - } - var socketError: Int32 = 0 - var errorLength = socklen_t(MemoryLayout.size) - guard getsockopt(socket, SOL_SOCKET, SO_ERROR, &socketError, &errorLength) == 0, socketError == 0 else { - throw TermindSSHError.socket(String(cString: strerror(socketError == 0 ? errno : socketError))) - } - } - - private static func writeAll(_ socket: Int32, _ data: Data) throws { - try data.withUnsafeBytes { bytes in - guard let base = bytes.baseAddress else { return } - var offset = 0 - while offset < bytes.count { - let written = Darwin.send(socket, base.advanced(by: offset), bytes.count - offset, 0) - guard written > 0 else { throw TermindSSHError.proxy(String(cString: strerror(errno))) } - offset += written - } - } - } - - private static func readSome(_ socket: Int32, maximum: Int) throws -> Data { - var buffer = [UInt8](repeating: 0, count: maximum) - let count = Darwin.recv(socket, &buffer, buffer.count, 0) - guard count > 0 else { - throw TermindSSHError.proxy( - count == 0 ? "The proxy closed the connection." : String(cString: strerror(errno))) - } - return Data(buffer.prefix(count)) - } - - private static func readExactly(_ socket: Int32, count: Int) throws -> Data { - var result = Data() - while result.count < count { result.append(try readSome(socket, maximum: count - result.count)) } - return result - } -} +typealias SSHSocketConnector = TermindRemoteInfrastructure.SSHSocketConnector diff --git a/TFM/Infrastructure/SSH/SSHUserCertificate.swift b/TFM/Infrastructure/SSH/SSHUserCertificate.swift index f7d2ae3e..f426b6a2 100644 --- a/TFM/Infrastructure/SSH/SSHUserCertificate.swift +++ b/TFM/Infrastructure/SSH/SSHUserCertificate.swift @@ -1,170 +1,4 @@ -import Foundation import TermindDomain -import struct TermindDomain.Host - -nonisolated enum SSHUserCertificateError: LocalizedError, Equatable { - case invalidFormat - case unsupportedAlgorithm(String) - case hostCertificate - case keyMismatch - - var errorDescription: String? { - switch self { - case .invalidFormat: - String(localized: "The certificate is not a valid OpenSSH user certificate.") - case .unsupportedAlgorithm(let algorithm): - String(localized: "Unsupported SSH certificate algorithm: \(algorithm)") - case .hostCertificate: - String(localized: "Host certificates cannot be used for user authentication.") - case .keyMismatch: - String(localized: "The certificate does not belong to this private key.") - } - } -} - -nonisolated struct SSHUserCertificate: Hashable, Sendable { - var authorizedKey: String - var algorithm: SSHKeyAlgorithm - var publicKeyBlob: Data - var serial: UInt64 - var keyID: String - var validAfter: Date - var validBefore: Date? - - var validityWarning: String? { - let now = Date() - if now < validAfter { - return String(localized: "This certificate is not valid yet.") - } - if let validBefore, now >= validBefore { - return String(localized: "This certificate has expired.") - } - return nil - } - - static func parse(_ text: String, matching publicKey: String? = nil) throws -> Self { - let normalized = text.trimmingCharacters(in: .whitespacesAndNewlines) - let fields = normalized.split(whereSeparator: \Character.isWhitespace) - guard fields.count >= 2, - let blob = Data(base64Encoded: String(fields[1])) - else { throw SSHUserCertificateError.invalidFormat } - - var reader = SSHWireReader(data: blob) - guard let certificateMethodData = reader.readField(), - let certificateMethod = String(data: certificateMethodData, encoding: .utf8), - certificateMethod == String(fields[0]), - certificateMethod.hasSuffix("-cert-v01@openssh.com"), - reader.readField() != nil - else { throw SSHUserCertificateError.invalidFormat } - - let algorithm: SSHKeyAlgorithm - let plainMethod: String - let keyFields: [Data] - switch certificateMethod { - case "ssh-ed25519-cert-v01@openssh.com": - algorithm = .ed25519 - plainMethod = SSHKeyAlgorithm.ed25519.rawValue - keyFields = try reader.readFields(count: 1) - case "ecdsa-sha2-nistp256-cert-v01@openssh.com": - algorithm = .ecdsa256 - plainMethod = SSHKeyAlgorithm.ecdsa256.rawValue - keyFields = try reader.readFields(count: 2) - case "ecdsa-sha2-nistp384-cert-v01@openssh.com": - algorithm = .ecdsa384 - plainMethod = SSHKeyAlgorithm.ecdsa384.rawValue - keyFields = try reader.readFields(count: 2) - case "ecdsa-sha2-nistp521-cert-v01@openssh.com": - algorithm = .ecdsa521 - plainMethod = SSHKeyAlgorithm.ecdsa521.rawValue - keyFields = try reader.readFields(count: 2) - case "ssh-rsa-cert-v01@openssh.com", - "rsa-sha2-256-cert-v01@openssh.com", - "rsa-sha2-512-cert-v01@openssh.com": - algorithm = .rsa - plainMethod = SSHKeyAlgorithm.rsa.rawValue - keyFields = try reader.readFields(count: 2) - default: - throw SSHUserCertificateError.unsupportedAlgorithm(certificateMethod) - } - - guard let serial = reader.readUInt64(), let type = reader.readUInt32(), - let keyIDData = reader.readField(), - reader.readField() != nil, - let validAfter = reader.readUInt64(), let validBefore = reader.readUInt64(), - reader.readField() != nil, reader.readField() != nil, - reader.readField() != nil, reader.readField() != nil, - reader.readField() != nil, reader.isAtEnd - else { throw SSHUserCertificateError.invalidFormat } - guard type == 1 else { - if type == 2 { throw SSHUserCertificateError.hostCertificate } - throw SSHUserCertificateError.invalidFormat - } - - let publicBlob = - SSHWireWriter.field(Data(plainMethod.utf8)) - + keyFields.reduce(into: Data()) { $0.append(SSHWireWriter.field($1)) } - if let publicKey { - let publicFields = publicKey.split(whereSeparator: \Character.isWhitespace) - guard publicFields.count >= 2, - let expected = Data(base64Encoded: String(publicFields[1])), - expected == publicBlob - else { throw SSHUserCertificateError.keyMismatch } - } - - return Self( - authorizedKey: normalized, algorithm: algorithm, publicKeyBlob: publicBlob, - serial: serial, keyID: String(data: keyIDData, encoding: .utf8) ?? "", - validAfter: Date(timeIntervalSince1970: TimeInterval(validAfter)), - validBefore: validBefore == UInt64.max - ? nil : Date(timeIntervalSince1970: TimeInterval(validBefore)) - ) - } -} - -private nonisolated struct SSHWireReader { - let data: Data - var offset = 0 - var isAtEnd: Bool { offset == data.count } - - mutating func readUInt32() -> UInt32? { - guard offset + 4 <= data.count else { return nil } - let value = data[offset..<(offset + 4)].reduce(UInt32(0)) { - ($0 << 8) | UInt32($1) - } - offset += 4 - return value - } - - mutating func readUInt64() -> UInt64? { - guard offset + 8 <= data.count else { return nil } - let value = data[offset..<(offset + 8)].reduce(UInt64(0)) { - ($0 << 8) | UInt64($1) - } - offset += 8 - return value - } - - mutating func readField() -> Data? { - guard let length = readUInt32(), offset + Int(length) <= data.count - else { return nil } - defer { offset += Int(length) } - return data[offset..<(offset + Int(length))] - } - - mutating func readFields(count: Int) throws -> [Data] { - var result: [Data] = [] - for _ in 0.. Data { - var length = UInt32(data.count).bigEndian - return withUnsafeBytes(of: &length) { Data($0) } + data - } -} +typealias SSHUserCertificateError = TermindDomain.SSHUserCertificateError +typealias SSHUserCertificate = TermindDomain.SSHUserCertificate diff --git a/TFM/Infrastructure/SSHImport/SSHImportReader.swift b/TFM/Infrastructure/SSHImport/SSHImportReader.swift index c0c395db..f7733f0b 100644 --- a/TFM/Infrastructure/SSHImport/SSHImportReader.swift +++ b/TFM/Infrastructure/SSHImport/SSHImportReader.swift @@ -1,217 +1,12 @@ import Foundation -import TermindDomain +import TermindRemoteInfrastructure -/// One import session owns its secrets. Only metadata leaves this actor until commit. -actor SSHImportReader: SSHImportReading { - private struct Material { - var text: String - var certificate: String +typealias SSHImportReader = TermindRemoteInfrastructure.SSHImportReader + +extension SSHImportReader { + init() { + self.init( + username: NSUserName(), + home: URL(fileURLWithPath: NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory())) } - private var materials: [UUID: Material] = [:] - private var stableIDs: [String: UUID] = [:] - - func discard() { materials.removeAll() } - - func scan(root: URL, knownHostsOnly: Bool, grants: [URL] = []) throws -> [SSHImportItem] { - let scopes = [root] + grants - let acquired = scopes.filter { $0.startAccessingSecurityScopedResource() } - defer { for url in acquired { url.stopAccessingSecurityScopedResource() } } - materials.removeAll() - let sourceType = try root.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey]) - guard knownHostsOnly ? sourceType.isRegularFile == true : sourceType.isDirectory == true else { - throw knownHostsOnly ? SSHImportSourceError.expectedFile : SSHImportSourceError.expectedFolder - } - if knownHostsOnly { - return stabilize(SSHImportParser.knownHosts(try read(root), source: root.path)) - } - let root = root.standardizedFileURL - let authorized = scopes.map { $0.resolvingSymlinksInPath().standardizedFileURL.path } - func allowed(_ url: URL) -> Bool { - let path = url.resolvingSymlinksInPath().standardizedFileURL.path - return authorized.contains { path == $0 || path.hasPrefix($0 + "/") } - } - var files: [String: String] = [:] - var inaccessible: [String] = [] - var fileCount = 0 - var totalBytes = 0 - let manager = FileManager.default - for scope in scopes { - var directory: ObjCBool = false - guard manager.fileExists(atPath: scope.path, isDirectory: &directory) else { continue } - let urls: [URL] - if directory.boolValue { - let enumerator = manager.enumerator( - at: scope, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey], options: []) - urls = enumerator?.allObjects.compactMap { $0 as? URL } ?? [] - } else { - urls = [scope] - } - for url in urls { - let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey]) - guard values?.isRegularFile == true || values?.isSymbolicLink == true else { continue } - guard allowed(url) else { - inaccessible.append(url.path) - continue - } - fileCount += 1 - guard fileCount <= 4096 else { throw CocoaError(.fileReadTooLarge) } - do { - let value = try read(url) - totalBytes += value.utf8.count - guard totalBytes <= 64 * 1024 * 1024 else { throw CocoaError(.fileReadTooLarge) } - files[url.standardizedFileURL.path] = value - } catch { - inaccessible.append(url.path) - } - } - } - var parser = SSHConfigParser( - root: root, home: URL(fileURLWithPath: NSHomeDirectoryForUser(NSUserName()) ?? NSHomeDirectory()), - username: NSUserName(), files: files) - var items = parser.parse() - let knownPath = root.appendingPathComponent("known_hosts").path - if let known = files[knownPath] { items += SSHImportParser.knownHosts(known, source: knownPath) } - let hosts = items.compactMap(\.host) - let referenced = Set(hosts.flatMap(\.identityPaths)) - let privateFiles = files.keys.filter { isPrivateKey(files[$0] ?? "") } - let paths = Set(privateFiles).union(referenced.filter { files[$0] != nil }) - for path in paths.sorted() { - let content = files[path] ?? "" - guard isPrivateKey(content) else { - items.append( - SSHImportItem( - category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, - source: path, selected: false, - issue: String(localized: "No supported private key in this file."), unavailable: true)) - continue - } - let explicitCertificates = hosts.filter { $0.identityPaths.contains(path) }.flatMap(\.certificatePaths) - let certificatePaths = Set(explicitCertificates + [path + "-cert.pub"]) - var variants: [String: [String]] = [:] - for certificatePath in certificatePaths.sorted() { - if let certificate = files[certificatePath] { - variants[certificate, default: []].append(certificatePath) - } - } - if files[path + "-cert.pub"] == nil { variants[""] = [] } - for certificate in variants.keys.sorted() { - let stable = path + "\n" + certificate - let id = stableID(stable) - materials[id] = Material(text: content, certificate: certificate) - var item = SSHImportItem( - id: id, category: .keys, name: URL(fileURLWithPath: path).lastPathComponent, source: path) - item.certificatePaths = variants[certificate] ?? [] - do { - item.key = try inspect(id: id, name: item.name, passphrase: nil) - } catch { - if isEncrypted(content) { - item.requiresPassphrase = true - item.issue = String(localized: "Enter the key passphrase to validate this private key.") - } else { - item.selected = false - item.unavailable = true - item.issue = String(localized: "No supported private key in this file.") - } - } - items.append(item) - } - } - let missing = hosts.filter { !$0.usesDefaultIdentityFiles }.flatMap(\.identityPaths).filter { files[$0] == nil } - let certificateMissing = hosts.flatMap(\.certificatePaths).filter { files[$0] == nil } - for path in Set(inaccessible).union(parser.externalPaths).union(missing).union(certificateMissing).sorted() { - items.append( - SSHImportItem( - category: path == knownPath ? .knownHosts : parser.externalPaths.contains(path) ? .hosts : .keys, - name: URL(fileURLWithPath: path).lastPathComponent, source: path, - issue: String(localized: "Authorize access to this referenced file, or deselect it."), - needsAuthorization: path)) - } - // Missing explicit references must also be visible on the affected host. - for index in items.indices { - guard let host = items[index].host else { continue } - let absent = (host.identityPaths + host.certificatePaths).filter { - files[$0] == nil && !(host.usesDefaultIdentityFiles && host.identityPaths.contains($0)) - } - items[index].warnings += absent.map { String(localized: "Referenced file is unavailable:") + " " + $0 } - } - return stabilize(items) - } - - func unlock(id: UUID, name: String, passphrase: String) throws -> SSHKeyRef { - try inspect(id: id, name: name, passphrase: passphrase) - } - - func credentials(for ids: Set) throws -> [UUID: StoredSSHKeyCredential] { - var result: [UUID: StoredSSHKeyCredential] = [:] - for id in ids { - guard let material = materials[id] else { throw CocoaError(.fileReadUnknown) } - result[id] = .software(SSHPrivateKeySecret(privateKey: material.text)) - } - return result - } - - private func inspect(id: UUID, name: String, passphrase: String?) throws -> SSHKeyRef { - guard let input = materials[id] else { throw CocoaError(.fileReadUnknown) } - let material = try SSHKeyMaterial.inspect(privateKey: input.text, passphrase: passphrase) - if !input.certificate.isEmpty { - _ = try SSHUserCertificate.parse(input.certificate, matching: material.publicKey) - } - return SSHKeyRef( - id: id, label: name, algorithm: material.algorithm, - publicKey: material.publicKey, certificate: input.certificate, fingerprint: material.fingerprint, - credentialRef: "termind.key.\(id.uuidString)", storage: .iCloud, keySize: material.keySize, - hasPassphrase: passphrase?.isEmpty == false, createdAt: Date()) - } - - private func stableID(_ source: String) -> UUID { - if let id = stableIDs[source] { return id } - let id = UUID() - stableIDs[source] = id - return id - } - - private func stabilize(_ values: [SSHImportItem]) -> [SSHImportItem] { - var occurrences: [String: Int] = [:] - return values.map { item in - var item = item - if materials[item.id] == nil { - let token = "\(item.category.rawValue):\(item.source):\(item.name)" - let occurrence = occurrences[token, default: 0] - occurrences[token] = occurrence + 1 - item.id = stableID(token + ":\(occurrence)") - } - return item - } - } - - private func read(_ url: URL) throws -> String { - let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) - guard values.isRegularFile == true, (values.fileSize ?? 0) <= 8 * 1024 * 1024 else { - throw CocoaError(.fileReadTooLarge) - } - return try String(contentsOf: url, encoding: .utf8) - } - - private func isEncrypted(_ text: String) -> Bool { - if text.contains("-----BEGIN ENCRYPTED PRIVATE KEY-----") || text.contains("DEK-Info:") { return true } - guard text.hasPrefix("-----BEGIN OPENSSH PRIVATE KEY-----") else { return false } - let encoded = text.components(separatedBy: .newlines).filter { !$0.hasPrefix("-----") }.joined() - let magic = Data("openssh-key-v1\0".utf8) - guard let data = Data(base64Encoded: encoded), data.starts(with: magic), data.count >= magic.count + 4 else { - return false - } - let offset = magic.count - let length = data[offset.. Bool { - [ - "-----BEGIN OPENSSH PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----", - "-----BEGIN PRIVATE KEY-----", "-----BEGIN ENCRYPTED PRIVATE KEY-----", "-----BEGIN DSA PRIVATE KEY-----", - ].contains { text.hasPrefix($0) } - } - } diff --git a/TFM/Infrastructure/Security/AISecretStore.swift b/TFM/Infrastructure/Security/AISecretStore.swift index 995f51fc..ccf2bc90 100644 --- a/TFM/Infrastructure/Security/AISecretStore.swift +++ b/TFM/Infrastructure/Security/AISecretStore.swift @@ -1,101 +1,8 @@ -import Foundation import SwiftData import TermindPersistence -/// Migration metadata is local. The reference is shared; the secret never enters SwiftData. -@MainActor -final class AISecretStore: AISecretStoring { - private let context: ModelContext - private let keychain: any SecretStoring - var uploadsAuthorized = false +typealias AISecretStore = SharedAISecretStore - init(context: ModelContext, keychain: any SecretStoring = KeychainSecretStore()) { - self.context = context - self.keychain = keychain - } - - func string(for reference: String) throws -> String? { - guard !reference.isEmpty else { return nil } - let location = try location(for: reference) - guard !location.pendingDeletion else { return nil } - return try keychain.string(for: reference, storage: location.usesICloud ? .iCloud : .thisDevice) - } - - func setString(_ value: String?, for reference: String) throws { - guard !reference.isEmpty else { return } - let location = try location(for: reference) - if value == nil { - location.pendingDeletion = true - try context.save() - try finishDeletion(location) - return - } - let cloud = location.usesICloud || uploadsAuthorized - try keychain.setString(value, for: reference, storage: cloud ? .iCloud : .thisDevice) - if cloud { - guard try keychain.string(for: reference, storage: .iCloud) == value else { - throw KeychainSecretError.unexpectedValue - } - } - location.usesICloud = cloud - location.pendingDeletion = false - try context.save() - if cloud { try keychain.setString(nil, for: reference, storage: .thisDevice) } - } - - /// Called only after the coordinator has checked the account and merge consent. - func migrate(_ references: Set) throws { - guard uploadsAuthorized else { return } - for row in try context.fetch(FetchDescriptor()) where row.pendingDeletion { - try finishDeletion(row) - } - for reference in references.sorted() where !reference.isEmpty { - let row = try location(for: reference) - guard !row.pendingDeletion else { continue } - if let local = try keychain.string(for: reference, storage: .thisDevice) { - // A previously synced value can be newer than a leftover local copy. - if try keychain.string(for: reference, storage: .iCloud) == nil && !row.usesICloud { - try keychain.setString(local, for: reference, storage: .iCloud) - guard try keychain.string(for: reference, storage: .iCloud) == local else { - throw KeychainSecretError.unexpectedValue - } - } - if try keychain.string(for: reference, storage: .iCloud) != nil { - row.usesICloud = true - try context.save() - try keychain.setString(nil, for: reference, storage: .thisDevice) - } - } else { - row.usesICloud = true - try context.save() - } - } - } - - func acceptRemoteReferences(_ references: Set) throws { - try context.commit { - for reference in references where !reference.isEmpty { - try location(for: reference).usesICloud = true - } - } - } - - private func finishDeletion(_ row: PersistentAISecretLocation) throws { - if row.usesICloud { try keychain.setString(nil, for: row.reference, storage: .iCloud) } - try keychain.setString(nil, for: row.reference, storage: .thisDevice) - // Retain the deletion marker so a delayed Keychain delivery cannot be used. - } - - private func location(for reference: String) throws -> PersistentAISecretLocation { - guard reference.hasPrefix("termind.ai."), reference.count > "termind.ai.".count else { - throw KeychainSecretError.invalidReference - } - var descriptor = FetchDescriptor( - predicate: #Predicate { $0.reference == reference }) - descriptor.fetchLimit = 1 - if let row = try context.fetch(descriptor).first { return row } - let row = PersistentAISecretLocation(reference: reference) - context.insert(row) - return row - } +extension AISecretStore { + convenience init(context: ModelContext) { self.init(context: context, keychain: KeychainSecretStore()) } } diff --git a/TFM/Infrastructure/Security/KeychainSecretStore.swift b/TFM/Infrastructure/Security/KeychainSecretStore.swift index c1d15768..14c846f7 100644 --- a/TFM/Infrastructure/Security/KeychainSecretStore.swift +++ b/TFM/Infrastructure/Security/KeychainSecretStore.swift @@ -1,205 +1,9 @@ -import Foundation -import Security -import TermindDomain +import TermindInfrastructure +import TermindRemoteInfrastructure -nonisolated enum KeychainSecretError: LocalizedError { - case status(OSStatus) - case invalidReference - case unexpectedValue +typealias KeychainSecretError = TermindRemoteInfrastructure.KeychainSecretError +typealias KeychainSecretStore = TermindRemoteInfrastructure.KeychainSecretStore - var errorDescription: String? { - switch self { - case .status(let status): - (SecCopyErrorMessageString(status, nil) as String?) - ?? String(localized: "Keychain error \(status)") - case .invalidReference: - String(localized: "The Keychain reference has the wrong credential type.") - case .unexpectedValue: - String(localized: "The Keychain item does not contain the expected credential type.") - } - } -} - -/// The Vault's typed secret boundary. Keeping the store behind this small -/// interface lets connection tests prove password/passphrase lifetime rules -/// without depending on a signed Keychain-enabled test host. -/// Typed storage in the macOS Data Protection Keychain. Independent services -/// prevent a damaged reference from reinterpreting one credential as another. -nonisolated struct KeychainSecretStore: SecretStoring, Sendable { - private static let passwordService = "com.akinokaede.termind.passwords.v1" - private static let softwareKeyService = "com.akinokaede.termind.ssh-software-keys.v1" - private static let enclaveService = "com.akinokaede.termind.secure-enclave-handles.v1" - - private let accessGroup: String - - init(accessGroup: String = ApplicationIdentity.keychainAccessGroup) { - self.accessGroup = accessGroup - } - - func string(for account: String, storage: PasswordStorage) throws -> String? { - try validateStringReference(account) - guard - let data = try data( - for: account, service: Self.passwordService, - synchronizable: storage == .iCloud - ) - else { return nil } - guard let value = String(data: data, encoding: .utf8) else { - throw KeychainSecretError.unexpectedValue - } - return value - } - - func setString(_ value: String?, for account: String, storage: PasswordStorage) throws { - try validateStringReference(account) - try setData( - value?.data(using: .utf8), for: account, service: Self.passwordService, - synchronizable: storage == .iCloud, - accessible: storage == .iCloud - ? kSecAttrAccessibleWhenUnlocked - : kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ) - } - - func keyCredential(for account: String, storage: KeyStorage) throws - -> StoredSSHKeyCredential? - { - try validate(account, prefix: "termind.key.") - guard - let data = try data( - for: account, service: service(for: storage), - synchronizable: storage == .iCloud - ) - else { return nil } - switch storage { - case .thisDevice, .iCloud: - return .software(try JSONDecoder().decode(SSHPrivateKeySecret.self, from: data)) - case .secureEnclave: - guard !data.isEmpty else { throw KeychainSecretError.unexpectedValue } - return .secureEnclave(SecureEnclaveKeyCredential(dataRepresentation: data)) - } - } - - func setKeyCredential( - _ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage - ) throws { - try validate(account, prefix: "termind.key.") - let data: Data? - switch (value, storage) { - case (.software(let secret), .thisDevice), (.software(let secret), .iCloud): - data = try JSONEncoder().encode(secret) - case (.secureEnclave(let credential), .secureEnclave): - data = credential.dataRepresentation - case (nil, _): - data = nil - default: - throw KeychainSecretError.unexpectedValue - } - try setData( - data, for: account, service: service(for: storage), - synchronizable: storage == .iCloud, - accessible: storage == .iCloud - ? kSecAttrAccessibleWhenUnlocked - : kSecAttrAccessibleWhenUnlockedThisDeviceOnly - ) - } - - func removePassword( - _ account: String, storage: PasswordStorage = .thisDevice - ) throws { - guard !account.isEmpty else { return } - try validate(account, prefix: "termind.password.") - try remove( - account, service: Self.passwordService, - synchronizable: storage == .iCloud - ) - } - - func removeKeyCredential(_ account: String, storage: KeyStorage) throws { - guard !account.isEmpty else { return } - try validate(account, prefix: "termind.key.") - try remove( - account, service: service(for: storage), - synchronizable: storage == .iCloud - ) - } - - private func data(for account: String, service: String, synchronizable: Bool) throws - -> Data? - { - guard !account.isEmpty else { return nil } - var query = baseQuery(account, service: service, synchronizable: synchronizable) - query[kSecMatchLimit as String] = kSecMatchLimitOne - query[kSecReturnData as String] = true - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess else { throw KeychainSecretError.status(status) } - guard let data = result as? Data else { throw KeychainSecretError.unexpectedValue } - return data - } - - private func setData( - _ value: Data?, for account: String, service: String, - synchronizable: Bool, accessible: CFString - ) throws { - guard let value else { - try remove(account, service: service, synchronizable: synchronizable) - return - } - let match = baseQuery(account, service: service, synchronizable: synchronizable) - let attributes: [String: Any] = [ - kSecValueData as String: value, - kSecAttrAccessible as String: accessible, - ] - let updateStatus = SecItemUpdate(match as CFDictionary, attributes as CFDictionary) - if updateStatus == errSecSuccess { return } - guard updateStatus == errSecItemNotFound else { - throw KeychainSecretError.status(updateStatus) - } - var add = match - for (key, value) in attributes { add[key] = value } - let addStatus = SecItemAdd(add as CFDictionary, nil) - guard addStatus == errSecSuccess else { throw KeychainSecretError.status(addStatus) } - } - - private func remove(_ account: String, service: String, synchronizable: Bool) throws { - let query = baseQuery(account, service: service, synchronizable: synchronizable) - let status = SecItemDelete(query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { - throw KeychainSecretError.status(status) - } - } - - private func baseQuery( - _ account: String, service: String, synchronizable: Bool - ) -> [String: Any] { - [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: account, - kSecAttrAccessGroup as String: accessGroup, - kSecAttrSynchronizable as String: synchronizable, - kSecUseDataProtectionKeychain as String: true, - ] - } - - private func service(for storage: KeyStorage) -> String { - switch storage { - case .thisDevice, .iCloud: Self.softwareKeyService - case .secureEnclave: Self.enclaveService - } - } - - private func validate(_ account: String, prefix: String) throws { - guard account.hasPrefix(prefix), account.count > prefix.count else { - throw KeychainSecretError.invalidReference - } - } - - private func validateStringReference(_ account: String) throws { - let prefixes = ["termind.password.", "termind.ai."] - guard prefixes.contains(where: { account.hasPrefix($0) && account.count > $0.count }) - else { throw KeychainSecretError.invalidReference } - } +extension KeychainSecretStore { + init() { self.init(accessGroup: ApplicationIdentity.keychainAccessGroup) } } diff --git a/TFM/Infrastructure/Security/SecureEnclaveKey.swift b/TFM/Infrastructure/Security/SecureEnclaveKey.swift index 6ca58344..24ad8fbc 100644 --- a/TFM/Infrastructure/Security/SecureEnclaveKey.swift +++ b/TFM/Infrastructure/Security/SecureEnclaveKey.swift @@ -1,112 +1,5 @@ -import CryptoKit -import Foundation -import LocalAuthentication -import Security +import TermindInfrastructure +import TermindRemoteInfrastructure -nonisolated enum SecureEnclaveKeyError: LocalizedError { - case unavailable - case accessControl(String) - case invalidSignature - - var errorDescription: String? { - switch self { - case .unavailable: - String(localized: "Secure Enclave is not available on this Mac.") - case .accessControl(let detail): - String(localized: "Secure Enclave access could not be configured: \(detail)") - case .invalidSignature: - String(localized: "Secure Enclave returned an invalid P-256 signature.") - } - } -} - -nonisolated enum SecureEnclaveKey { - static var isAvailable: Bool { SecureEnclave.isAvailable } - - static func generate(comment: String, requiresUserPresence: Bool) throws - -> SecureEnclaveKeyMaterial - { - guard isAvailable else { throw SecureEnclaveKeyError.unavailable } - - var flags: SecAccessControlCreateFlags = [.privateKeyUsage] - if requiresUserPresence { flags.insert(.userPresence) } - var accessError: Unmanaged? - guard - let accessControl = SecAccessControlCreateWithFlags( - nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, flags, &accessError - ) - else { - let detail = - accessError?.takeRetainedValue().localizedDescription - ?? String(localized: "Unknown Security framework error") - throw SecureEnclaveKeyError.accessControl(detail) - } - - let privateKey = try SecureEnclave.P256.Signing.PrivateKey( - compactRepresentable: false, - accessControl: accessControl - ) - let publicBlob = sshPublicBlob(for: privateKey.publicKey.x963Representation) - let trimmedComment = comment.trimmingCharacters(in: .whitespacesAndNewlines) - let publicKey = - "ecdsa-sha2-nistp256 \(publicBlob.base64EncodedString())" - + (trimmedComment.isEmpty ? "" : " \(trimmedComment)") - return SecureEnclaveKeyMaterial( - credential: SecureEnclaveKeyCredential( - dataRepresentation: privateKey.dataRepresentation - ), - publicKey: publicKey, - fingerprint: fingerprint(of: publicBlob) - ) - } - - /// Returns the SSH ECDSA signature payload: two length-prefixed mpints (r, s). - /// libssh2 adds the outer algorithm and signature strings around this value. - static func sign(_ data: Data, credential: SecureEnclaveKeyCredential) throws -> Data { - guard isAvailable else { throw SecureEnclaveKeyError.unavailable } - let context = LAContext() - context.localizedReason = String(localized: "Use your Secure Enclave SSH key") - let privateKey = try SecureEnclave.P256.Signing.PrivateKey( - dataRepresentation: credential.dataRepresentation, - authenticationContext: context - ) - let rawSignature = try privateKey.signature(for: data).rawRepresentation - guard rawSignature.count == 64 else { throw SecureEnclaveKeyError.invalidSignature } - return sshField(mpint(rawSignature.prefix(32))) - + sshField(mpint(rawSignature.suffix(32))) - } - - static func publicBlob(from authorizedKey: String) -> Data? { - let parts = authorizedKey.split(whereSeparator: \Character.isWhitespace) - guard parts.count >= 2, parts[0] == "ecdsa-sha2-nistp256" else { return nil } - return Data(base64Encoded: String(parts[1])) - } - - private static func sshPublicBlob(for x963Representation: Data) -> Data { - sshField(Data("ecdsa-sha2-nistp256".utf8)) - + sshField(Data("nistp256".utf8)) - + sshField(x963Representation) - } - - private static func fingerprint(of blob: Data) -> String { - let digest = Data(SHA256.hash(data: blob)).base64EncodedString() - .replacingOccurrences(of: "=", with: "") - return "SHA256:\(digest)" - } - - private static func mpint(_ bytes: S) -> Data { - var value = Data(bytes).drop(while: { $0 == 0 }) - if value.isEmpty { return Data() } - if value.first.map({ $0 & 0x80 != 0 }) == true { - value.insert(0, at: value.startIndex) - } - return Data(value) - } - - private static func sshField(_ value: Data) -> Data { - var length = UInt32(value.count).bigEndian - var result = withUnsafeBytes(of: &length) { Data($0) } - result.append(value) - return result - } -} +typealias SecureEnclaveKeyError = TermindRemoteInfrastructure.SecureEnclaveKeyError +typealias SecureEnclaveKey = TermindRemoteInfrastructure.SecureEnclaveKey diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index cdb73f3a..df37dca8 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -58,7 +58,8 @@ struct ArchitectureBoundaryTests { contentsOf: sourceRoot.appending(path: "Application/Metrics/ServerMetricsModel.swift"), encoding: .utf8) #expect(!model.contains("hosts.sshConnectionConfiguration(")) let preparation = try String( - contentsOf: sourceRoot.appending(path: "Application/Connections/SSHConnectionPreparation.swift"), + contentsOf: sourceRoot.appending( + path: "../Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift"), encoding: .utf8) #expect(preparation.contains("Task.detached")) let view = try String( @@ -136,7 +137,7 @@ struct ArchitectureBoundaryTests { @Test func featureFacadesDoNotExposeTheirBackingLibrary() throws { - for file in try swiftFiles(in: "Application/FeatureModels") { + for file in try swiftFiles(in: "../Frameworks/TermindApplication/FeatureModels") { let text = try String(contentsOf: file, encoding: .utf8) #expect( !text.contains("\n let library: HostLibraryModel"), @@ -166,6 +167,7 @@ struct ArchitectureBoundaryTests { ] ) try assertNoConcreteImplementations(in: "Application") + try assertNoConcreteImplementations(in: "../Frameworks/TermindApplication") } @Test @@ -251,7 +253,10 @@ struct ArchitectureBoundaryTests { @Test func sharedFrameworksCannotImportPlatformAppsOrUI() throws { - for module in ["TermindDomain", "TermindApplication", "TermindInfrastructure", "TermindPersistence"] { + for module in [ + "TermindDomain", "TermindApplication", "TermindInfrastructure", "TermindRemoteInfrastructure", + "TermindPersistence", + ] { try assertNoImports( in: "../Frameworks/\(module)", forbidden: ["Termind", "TermindIOS", "AppKit", "UIKit", "IOKit", "ServiceManagement"]) @@ -261,7 +266,17 @@ struct ArchitectureBoundaryTests { forbidden: ["TermindInfrastructure", "TermindPersistence", "SwiftData"]) try assertNoImports( in: "../Frameworks/TermindPersistence", - forbidden: ["TermindApplication", "TermindInfrastructure"]) + forbidden: ["TermindInfrastructure", "TermindRemoteInfrastructure"]) + } + + @Test + func sharedPresentationHasNoPlatformOrStorageDependency() throws { + try assertNoImports( + in: "../Frameworks/TermindPresentation", + forbidden: [ + "Termind", "TermindIOS", "UIKit", "AppKit", "SwiftData", "TermindPersistence", + "TermindRemoteInfrastructure", + ]) } @Test diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index a5e9cf89..5b012a6b 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -8,13 +8,22 @@ /* Begin PBXBuildFile section */ 0202B16D630CC57432748F0C /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; + 03892FFD3F1E351BD2C296E4 /* TermindWidgets.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 32414DBD2157FBAD4D00056D /* TermindWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 082994E88792AF7D1F99B147 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; + 0861B3AA103F736DEE93983B /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; + 08620C447AFE3884EA26ADCC /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; + 0C77A0D7A810A78DA1F6CD93 /* CSSH2 in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100011 /* CSSH2 */; }; + 0D54DF6508C7918D7D5B9D5A /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100014 /* OpenSSL */; }; 10DD764492A94453449B3484 /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; 1D479CE3B3FF74B0C23BA3F2 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 20ADB83C9DB7F78EDF8BCBDF /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 23E272F32F769A979F3ABF42 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 25C0658E2DD9B2FE28023DA7 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; + 26C689ECB8B41A357765CCF1 /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; + 38462C442F34CFDE6FF4661D /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; + 38B690B07BB71C9BAA6B45B0 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; 3CEFDFDB25F7D9C9A795C601 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; + 5310B1DD3FA3FE77185B3F4A /* TermindRemoteInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; }; 640E0A8248C5231A7424BE56 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; 6995A68F3132E9C67B0DA01E /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; 6DF0C216374DA144B1FC2030 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; @@ -24,23 +33,31 @@ 7B51DD6FD59A860DDA7F80EE /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; 7DE0BDBF5E7A76602DDD443D /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; 81A97F4DC317A6940A7A2CA5 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; + 88E7F9CE7BC5631B487E2BC5 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; + 8BFD569E84CA09FDEFF87698 /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; + 8D2404FE022681C084DE0E23 /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; + 8F73CAE12C350F5FDB033EE0 /* GhosttyKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100016 /* GhosttyKit */; }; 8FCAF8FD0FF78E38C58ED887 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; 9AF99D2C7AF5E879598A5972 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; + A04F3C049C318FA491B42039 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; + A671EB6598B90BA03EF86CE7 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; + A9A947EA53E15BD04CE319C1 /* TermindPresentation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; }; + AB2210ABB10F707CBFB01C78 /* GhosttyTheme in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100013 /* GhosttyTheme */; }; B08DD88AB7C2444E5AEC19BC /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; B1821FD31E7D009C900B5557 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; B616FF213FC31346E6BB3817 /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; C2D4B372BA5A040DDBCEFC85 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; CF863393BB8C2B38A9EEE18A /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; + D7D1BBB65020F61F7EFDDFD7 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100012 /* GhosttyTerminal */; }; E635C54CA9F1A63D909CFCE6 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; }; EF2F88E4809C845C3F1BBF63 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; }; F6ACDF3012081771F6C8F659 /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; }; F996BFD771BC77E67AB91FFB /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; + FACB579B671967F922A33CD9 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; }; FB01141D5DD3036A40889B76 /* TermindInfrastructure.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */; }; TRMD00000000000000030001 /* termind-mcp in Embed MCP Bridge */ = {isa = PBXBuildFile; fileRef = TRMD00000000000000030002 /* termind-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; - TRMD00000000000000100001 /* CSSH2 in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100011 /* CSSH2 */; }; TRMD00000000000000100002 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100012 /* GhosttyTerminal */; }; TRMD00000000000000100003 /* GhosttyTheme in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100013 /* GhosttyTheme */; }; - TRMD00000000000000100004 /* OpenSSL in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100014 /* OpenSSL */; }; TRMD00000000000000100005 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; }; TRMD00000000000000100006 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100015 /* ZIPFoundation */; }; TRMD00000000000000100007 /* GhosttyKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD00000000000000100016 /* GhosttyKit */; }; @@ -54,6 +71,90 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 0A35D3902E3320DBDB756D0B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A5F3ED08556B8962920EF788; + remoteInfo = Framework; + }; + 25EB7B3558E87335BF52EBFD /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = ADAD84AB61B493DA1D22B54C; + remoteInfo = Framework; + }; + 266FBAED0BB986F8F0D1000B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 727F565FD44C8D180B283598; + remoteInfo = Framework; + }; + 2B98C090049C0DB38AA2199B /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 727F565FD44C8D180B283598; + remoteInfo = Framework; + }; + 2F5BAF1F7EA44EC2E1F6EA73 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A5F3ED08556B8962920EF788; + remoteInfo = Framework; + }; + 81596FC81A193992EB68BB60 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BDA6B93652DE90AEBDA7090E; + remoteInfo = Framework; + }; + 892065B84B7200DAAE43F5F2 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = BDA6B93652DE90AEBDA7090E; + remoteInfo = Framework; + }; + 8A1D0EA6890B1E7D3BDFE747 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B34524427DC3DF7A5835776C; + remoteInfo = TermindWidgets; + }; + 8D3CBDCC65F0ED530DC28977 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B243FDB536F5111B356353DA; + remoteInfo = Framework; + }; + B2A0F8D7119DC7A994252B95 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = B243FDB536F5111B356353DA; + remoteInfo = Framework; + }; + B9648C248A9D35BAD9BFBD82 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 727F565FD44C8D180B283598; + remoteInfo = Framework; + }; + BBDB8DE077D7709FC87EAD49 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = A5F3ED08556B8962920EF788; + remoteInfo = Framework; + }; DB8AB3CC3053A07800C7EFAE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = TRMD00000000000000000090 /* Project object */; @@ -229,6 +330,13 @@ remoteGlobalIDString = DB005287D48C7756E15B6830; remoteInfo = TermindPersistence; }; + DDD7C2043B64225E19B0D941 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = TRMD00000000000000000090 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 727F565FD44C8D180B283598; + remoteInfo = Framework; + }; TRMD00000000000000020090 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = TRMD00000000000000000090 /* Project object */; @@ -246,6 +354,17 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + B7FBAAD7AF587EA9ADB523C5 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 03892FFD3F1E351BD2C296E4 /* TermindWidgets.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; TRMD00000000000000000085 /* Embed MCP Bridge */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -263,8 +382,11 @@ 01FFEA64B9D6E13E50664FB8 /* Termind.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Termind.app; sourceTree = BUILT_PRODUCTS_DIR; }; 10111AE8F047FB59B318813A /* TermindApplication.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindApplication.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 13753997381D54B4D92FDDD5 /* TFITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TFITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindRemoteInfrastructure.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindPersistence.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 32414DBD2157FBAD4D00056D /* TermindWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; path = TermindWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindInfrastructure.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindPresentation.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7A62058825D6607920F1E2A1 /* TermindDomain.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = TermindDomain.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 8C9A1D5C026A485801BF7908 /* FrameworkTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FrameworkTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DBE378B130478CFB00B16AA1 /* Termind.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; name = Termind.storekit; path = StoreKit/Termind.storekit; sourceTree = ""; }; @@ -274,6 +396,20 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 20115C9164DA003CDD91B7BE /* Exceptions for "TFIWidgets" folder in "TermindWidgets" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = B34524427DC3DF7A5835776C /* TermindWidgets */; + }; + E26B867E4820091C8A63C738 /* Exceptions for "TFI" folder in "TFI" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 1EAB6B47E04D61C8CFC54762 /* TFI */; + }; TRMD00000000000000000011 /* Exceptions for "TFM" folder in "TFM" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -286,6 +422,9 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ 0FE0DF713B1C0832D76696AF /* TFI */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + E26B867E4820091C8A63C738 /* Exceptions for "TFI" folder in "TFI" target */, + ); path = TFI; sourceTree = ""; }; @@ -304,6 +443,11 @@ path = Resources; sourceTree = ""; }; + 735894552DE200D22AA043F6 /* TermindPresentation */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TermindPresentation; + sourceTree = ""; + }; 92C81B33B00182BDC162D7C9 /* TermindInfrastructure */ = { isa = PBXFileSystemSynchronizedRootGroup; path = TermindInfrastructure; @@ -324,6 +468,24 @@ path = FrameworkTests; sourceTree = ""; }; + CB622EAF077B1253532F1E18 /* TFIWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 20115C9164DA003CDD91B7BE /* Exceptions for "TFIWidgets" folder in "TermindWidgets" target */, + ); + path = TFIWidgets; + sourceTree = ""; + }; + E58718D8105B0F87D5986B4F /* TermindRemoteInfrastructure */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TermindRemoteInfrastructure; + sourceTree = ""; + }; + FD8DC40513A4A80A71C41FCA /* TFIActivityShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = TFIActivityShared; + sourceTree = ""; + }; TRMD00000000000000000010 /* TFM */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -345,6 +507,15 @@ /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ + 250F72DDA471D831C5727455 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A671EB6598B90BA03EF86CE7 /* TermindDomain.framework in Frameworks */, + 38B690B07BB71C9BAA6B45B0 /* TermindApplication.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 2F8570B27A408F55FC6F56E3 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -357,14 +528,32 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 38462C442F34CFDE6FF4661D /* AgentKit in Frameworks */, EF2F88E4809C845C3F1BBF63 /* TermindDomain.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + 75CB445EB4CF63B7BCBB2495 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 0C77A0D7A810A78DA1F6CD93 /* CSSH2 in Frameworks */, + 0D54DF6508C7918D7D5B9D5A /* OpenSSL in Frameworks */, + 0861B3AA103F736DEE93983B /* TermindInfrastructure.framework in Frameworks */, + 88E7F9CE7BC5631B487E2BC5 /* TermindDomain.framework in Frameworks */, + FACB579B671967F922A33CD9 /* TermindApplication.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7B09CFC5B32584951BD4C8F9 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5310B1DD3FA3FE77185B3F4A /* TermindRemoteInfrastructure.framework in Frameworks */, + 8F73CAE12C350F5FDB033EE0 /* GhosttyKit in Frameworks */, + AB2210ABB10F707CBFB01C78 /* GhosttyTheme in Frameworks */, + D7D1BBB65020F61F7EFDDFD7 /* GhosttyTerminal in Frameworks */, + 8D2404FE022681C084DE0E23 /* TermindPresentation.framework in Frameworks */, 8FCAF8FD0FF78E38C58ED887 /* TermindDomain.framework in Frameworks */, 9AF99D2C7AF5E879598A5972 /* TermindApplication.framework in Frameworks */, CF863393BB8C2B38A9EEE18A /* TermindInfrastructure.framework in Frameworks */, @@ -386,6 +575,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + A04F3C049C318FA491B42039 /* TermindApplication.framework in Frameworks */, 1D479CE3B3FF74B0C23BA3F2 /* AgentKit in Frameworks */, 25C0658E2DD9B2FE28023DA7 /* TermindDomain.framework in Frameworks */, ); @@ -395,6 +585,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 8BFD569E84CA09FDEFF87698 /* TermindPresentation.framework in Frameworks */, 76535271991F0D93E7277E6F /* TermindDomain.framework in Frameworks */, B08DD88AB7C2444E5AEC19BC /* TermindApplication.framework in Frameworks */, 10DD764492A94453449B3484 /* TermindInfrastructure.framework in Frameworks */, @@ -415,21 +606,29 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + FB0D6E6D0B8DBACFC8EA2B2B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A9A947EA53E15BD04CE319C1 /* TermindPresentation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; TRMD00000000000000000020 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 26C689ECB8B41A357765CCF1 /* TermindRemoteInfrastructure.framework in Frameworks */, + 08620C447AFE3884EA26ADCC /* TermindPresentation.framework in Frameworks */, TRMD0000000000000010000C /* AgentKit in Frameworks */, TRMD0000000000000010000E /* AgentKitScrubber in Frameworks */, TRMD0000000000000010000B /* GlyphixTextFx in Frameworks */, TRMD00000000000000100005 /* ZIPFoundation in Frameworks */, TRMD00000000000000100008 /* MarkdownView in Frameworks */, TRMD00000000000000100009 /* ListViewKit in Frameworks */, - TRMD00000000000000100001 /* CSSH2 in Frameworks */, TRMD00000000000000100007 /* GhosttyKit in Frameworks */, TRMD00000000000000100002 /* GhosttyTerminal in Frameworks */, TRMD00000000000000100003 /* GhosttyTheme in Frameworks */, - TRMD00000000000000100004 /* OpenSSL in Frameworks */, 7B23538CFDAF459CA4CB4351 /* TermindDomain.framework in Frameworks */, 640E0A8248C5231A7424BE56 /* TermindApplication.framework in Frameworks */, F996BFD771BC77E67AB91FFB /* TermindInfrastructure.framework in Frameworks */, @@ -467,6 +666,8 @@ 46E0A2AA823D19093256BE94 /* Resources */, 107C3841D2F5B0D215D798F0 /* TermindDomain */, 1352C351013C9A37E7DABE9E /* TermindApplication */, + E58718D8105B0F87D5986B4F /* TermindRemoteInfrastructure */, + 735894552DE200D22AA043F6 /* TermindPresentation */, 92C81B33B00182BDC162D7C9 /* TermindInfrastructure */, A93D181DCED7ACF38C25FA9E /* TermindPersistence */, ); @@ -476,6 +677,8 @@ TRMD00000000000000000030 = { isa = PBXGroup; children = ( + CB622EAF077B1253532F1E18 /* TFIWidgets */, + FD8DC40513A4A80A71C41FCA /* TFIActivityShared */, DBE378B130478CFB00B16AA1 /* Termind.storekit */, CDDB75F14F753E46BB14D972 /* Frameworks */, TRMD00000000000000000010 /* TFM */, @@ -491,11 +694,14 @@ TRMD00000000000000000040 /* Products */ = { isa = PBXGroup; children = ( + 32414DBD2157FBAD4D00056D /* TermindWidgets.appex */, TRMD00000000000000000001 /* Termind.app */, TRMD00000000000000020001 /* TFMTests.xctest */, TRMD00000000000000030002 /* termind-mcp */, 7A62058825D6607920F1E2A1 /* TermindDomain.framework */, 10111AE8F047FB59B318813A /* TermindApplication.framework */, + 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */, + 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */, 67B48098792E58C2955768F2 /* TermindInfrastructure.framework */, 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */, 01FFEA64B9D6E13E50664FB8 /* Termind.app */, @@ -512,6 +718,7 @@ isa = PBXNativeTarget; buildConfigurationList = 20CE6E8A93D697985F372EA6 /* Build configuration list for PBXNativeTarget "TFI" */; buildPhases = ( + B7FBAAD7AF587EA9ADB523C5 /* Embed App Extensions */, AC89B83FA8E4154383D9AC81 /* Sources */, 7B09CFC5B32584951BD4C8F9 /* Frameworks */, 8F0C5245592A0E2AFB85E50F /* Resources */, @@ -519,6 +726,9 @@ buildRules = ( ); dependencies = ( + E954DDEB2251D5DDF5B5E4D0 /* PBXTargetDependency */, + 969DB5ED9186D2B471A0BD11 /* PBXTargetDependency */, + 78A8722D62CD30AC7A4E0E02 /* PBXTargetDependency */, D6444C208365A08727ACC139 /* PBXTargetDependency */, 29FDCE2C09E8F0A257C594A5 /* PBXTargetDependency */, C2CB1C43BCBD82FF680630A0 /* PBXTargetDependency */, @@ -527,9 +737,13 @@ fileSystemSynchronizedGroups = ( 0FE0DF713B1C0832D76696AF /* TFI */, 46E0A2AA823D19093256BE94 /* Resources */, + FD8DC40513A4A80A71C41FCA /* TFIActivityShared */, ); name = TFI; packageProductDependencies = ( + TRMD00000000000000100016 /* GhosttyKit */, + TRMD00000000000000100013 /* GhosttyTheme */, + TRMD00000000000000100012 /* GhosttyTerminal */, TRMD0000000000000010001B /* AgentKit */, ); productName = TFI; @@ -564,6 +778,30 @@ productReference = 8C9A1D5C026A485801BF7908 /* FrameworkTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 727F565FD44C8D180B283598 /* TermindPresentation */ = { + isa = PBXNativeTarget; + buildConfigurationList = 5187A7B8CCE63A51982AB904 /* Build configuration list for PBXNativeTarget "TermindPresentation" */; + buildPhases = ( + 7F30AE9837284D35881E071C /* Sources */, + 250F72DDA471D831C5727455 /* Frameworks */, + 4C3532C482B667F9A7CBB31C /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 6FEC2C90F6E3A33FB42096D8 /* PBXTargetDependency */, + 3AEDD6414F1B16E4308F01FB /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 735894552DE200D22AA043F6 /* TermindPresentation */, + ); + name = TermindPresentation; + packageProductDependencies = ( + ); + productName = TermindPresentation; + productReference = 6F2CD72AE76428E896F81B64 /* TermindPresentation.framework */; + productType = "com.apple.product-type.framework"; + }; A5F3ED08556B8962920EF788 /* TermindApplication */ = { isa = PBXNativeTarget; buildConfigurationList = 976EB3B3A2F3583E74DF1E13 /* Build configuration list for PBXNativeTarget "TermindApplication" */; @@ -582,6 +820,7 @@ ); name = TermindApplication; packageProductDependencies = ( + TRMD0000000000000010001B /* AgentKit */, ); productName = TermindApplication; productReference = 10111AE8F047FB59B318813A /* TermindApplication.framework */; @@ -634,6 +873,58 @@ productReference = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; productType = "com.apple.product-type.framework"; }; + B34524427DC3DF7A5835776C /* TermindWidgets */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A55575DB0A0A07D5A5B38BE /* Build configuration list for PBXNativeTarget "TermindWidgets" */; + buildPhases = ( + 324CB4A18F9FD467A1BB8C8C /* Sources */, + FB0D6E6D0B8DBACFC8EA2B2B /* Frameworks */, + 2CA3E6071DF7C233129681FA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7694F0D5535EF9F706E23705 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 46E0A2AA823D19093256BE94 /* Resources */, + CB622EAF077B1253532F1E18 /* TFIWidgets */, + FD8DC40513A4A80A71C41FCA /* TFIActivityShared */, + ); + name = TermindWidgets; + packageProductDependencies = ( + ); + productName = TermindWidgets; + productReference = 32414DBD2157FBAD4D00056D /* TermindWidgets.appex */; + productType = "com.apple.product-type.app-extension"; + }; + BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */ = { + isa = PBXNativeTarget; + buildConfigurationList = 12F5D040E2844B6FD884BF65 /* Build configuration list for PBXNativeTarget "TermindRemoteInfrastructure" */; + buildPhases = ( + F599E47BAC4A976A849F668D /* Sources */, + 75CB445EB4CF63B7BCBB2495 /* Frameworks */, + E816FA2491D173780D089045 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 677C5AADCEEE4708387B0AFE /* PBXTargetDependency */, + 120C1C1E71C276EF090C7DC0 /* PBXTargetDependency */, + 6DB5920C1BFFC4D9795463D0 /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + E58718D8105B0F87D5986B4F /* TermindRemoteInfrastructure */, + ); + name = TermindRemoteInfrastructure; + packageProductDependencies = ( + TRMD00000000000000100011 /* CSSH2 */, + TRMD00000000000000100014 /* OpenSSL */, + ); + productName = TermindRemoteInfrastructure; + productReference = 21951230A1FF30987ADC3B6B /* TermindRemoteInfrastructure.framework */; + productType = "com.apple.product-type.framework"; + }; DB005287D48C7756E15B6830 /* TermindPersistence */ = { isa = PBXNativeTarget; buildConfigurationList = BD8BF81D53731B72A867D811 /* Build configuration list for PBXNativeTarget "TermindPersistence" */; @@ -645,6 +936,7 @@ buildRules = ( ); dependencies = ( + 7FF186C9DC087748DDFB9EAF /* PBXTargetDependency */, 00758A789ECB9643122A60EC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -669,6 +961,7 @@ buildRules = ( ); dependencies = ( + 4D3EA05C89BC456F30784AA5 /* PBXTargetDependency */, 0EF002FE2F91FCC695C1D40F /* PBXTargetDependency */, 4EB5324D41D2FA6D5726D73D /* PBXTargetDependency */, 191B454FB44C6FB85FA289A6 /* PBXTargetDependency */, @@ -677,6 +970,7 @@ ); fileSystemSynchronizedGroups = ( A102A7EA2C3532D7FA88B0A4 /* TFITests */, + FD8DC40513A4A80A71C41FCA /* TFIActivityShared */, ); name = TFITests; packageProductDependencies = ( @@ -698,6 +992,8 @@ buildRules = ( ); dependencies = ( + 360EC5FE18C1E44595290420 /* PBXTargetDependency */, + 33C62E8FC4C6680501A68A25 /* PBXTargetDependency */, TRMD00000000000000030091 /* PBXTargetDependency */, F98F58C51DE326AC4AF95F66 /* PBXTargetDependency */, C0B7F56205F86D0178F32100 /* PBXTargetDependency */, @@ -712,12 +1008,10 @@ packageProductDependencies = ( TRMD0000000000000010001B /* AgentKit */, TRMD0000000000000010001C /* AgentKitScrubber */, - TRMD00000000000000100011 /* CSSH2 */, TRMD0000000000000010001A /* GlyphixTextFx */, TRMD00000000000000100016 /* GhosttyKit */, TRMD00000000000000100012 /* GhosttyTerminal */, TRMD00000000000000100013 /* GhosttyTheme */, - TRMD00000000000000100014 /* OpenSSL */, TRMD00000000000000100015 /* ZIPFoundation */, TRMD00000000000000100017 /* MarkdownView */, TRMD00000000000000100018 /* ListViewKit */, @@ -839,6 +1133,9 @@ projectDirPath = ""; projectRoot = ""; targets = ( + BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */, + B34524427DC3DF7A5835776C /* TermindWidgets */, + 727F565FD44C8D180B283598 /* TermindPresentation */, TRMD00000000000000000050 /* TFM */, TRMD00000000000000020050 /* TFMTests */, TRMD00000000000000030050 /* TermindMCPBridge */, @@ -868,6 +1165,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2CA3E6071DF7C233129681FA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 4C3532C482B667F9A7CBB31C /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 6418B7148055671AAB2FB492 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -903,6 +1214,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + E816FA2491D173780D089045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; TRMD00000000000000000080 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -927,6 +1245,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 324CB4A18F9FD467A1BB8C8C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 7A66ED3CE1F6BC55177F5B13 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -934,6 +1259,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7F30AE9837284D35881E071C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 9134CC60C9D2F547DC97DAF6 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -969,6 +1301,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + F599E47BAC4A976A849F668D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; TRMD00000000000000000070 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1003,6 +1342,11 @@ target = 1EAB6B47E04D61C8CFC54762 /* TFI */; targetProxy = DB8AB3E03053A07900C7EFAE /* PBXContainerItemProxy */; }; + 120C1C1E71C276EF090C7DC0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B243FDB536F5111B356353DA /* TermindDomain */; + targetProxy = 8D3CBDCC65F0ED530DC28977 /* PBXContainerItemProxy */; + }; 191B454FB44C6FB85FA289A6 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A5F3ED08556B8962920EF788 /* TermindApplication */; @@ -1023,11 +1367,31 @@ target = A5F3ED08556B8962920EF788 /* TermindApplication */; targetProxy = DB8AB3CD3053A07800C7EFAE /* PBXContainerItemProxy */; }; + 33C62E8FC4C6680501A68A25 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 727F565FD44C8D180B283598 /* TermindPresentation */; + targetProxy = 2B98C090049C0DB38AA2199B /* PBXContainerItemProxy */; + }; + 360EC5FE18C1E44595290420 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */; + targetProxy = 892065B84B7200DAAE43F5F2 /* PBXContainerItemProxy */; + }; + 3AEDD6414F1B16E4308F01FB /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A5F3ED08556B8962920EF788 /* TermindApplication */; + targetProxy = 2F5BAF1F7EA44EC2E1F6EA73 /* PBXContainerItemProxy */; + }; 438F9D0971977DE55A9319FE /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB005287D48C7756E15B6830 /* TermindPersistence */; targetProxy = DB8AB3DB3053A07900C7EFAE /* PBXContainerItemProxy */; }; + 4D3EA05C89BC456F30784AA5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 727F565FD44C8D180B283598 /* TermindPresentation */; + targetProxy = 266FBAED0BB986F8F0D1000B /* PBXContainerItemProxy */; + }; 4EB5324D41D2FA6D5726D73D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B243FDB536F5111B356353DA /* TermindDomain */; @@ -1043,6 +1407,21 @@ target = DB005287D48C7756E15B6830 /* TermindPersistence */; targetProxy = DB8AB3D73053A07800C7EFAE /* PBXContainerItemProxy */; }; + 677C5AADCEEE4708387B0AFE /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = ADAD84AB61B493DA1D22B54C /* TermindInfrastructure */; + targetProxy = 25EB7B3558E87335BF52EBFD /* PBXContainerItemProxy */; + }; + 6DB5920C1BFFC4D9795463D0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A5F3ED08556B8962920EF788 /* TermindApplication */; + targetProxy = BBDB8DE077D7709FC87EAD49 /* PBXContainerItemProxy */; + }; + 6FEC2C90F6E3A33FB42096D8 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B243FDB536F5111B356353DA /* TermindDomain */; + targetProxy = B2A0F8D7119DC7A994252B95 /* PBXContainerItemProxy */; + }; 710D3CAF3582192E63670379 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B243FDB536F5111B356353DA /* TermindDomain */; @@ -1053,11 +1432,31 @@ target = DB005287D48C7756E15B6830 /* TermindPersistence */; targetProxy = DB8AB3E43053A07900C7EFAE /* PBXContainerItemProxy */; }; + 7694F0D5535EF9F706E23705 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 727F565FD44C8D180B283598 /* TermindPresentation */; + targetProxy = DDD7C2043B64225E19B0D941 /* PBXContainerItemProxy */; + }; + 78A8722D62CD30AC7A4E0E02 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 727F565FD44C8D180B283598 /* TermindPresentation */; + targetProxy = B9648C248A9D35BAD9BFBD82 /* PBXContainerItemProxy */; + }; + 7FF186C9DC087748DDFB9EAF /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = A5F3ED08556B8962920EF788 /* TermindApplication */; + targetProxy = 0A35D3902E3320DBDB756D0B /* PBXContainerItemProxy */; + }; 81CC5F621EFB712B902CD692 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = A5F3ED08556B8962920EF788 /* TermindApplication */; targetProxy = DB8AB3D13053A07800C7EFAE /* PBXContainerItemProxy */; }; + 969DB5ED9186D2B471A0BD11 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = B34524427DC3DF7A5835776C /* TermindWidgets */; + targetProxy = 8A1D0EA6890B1E7D3BDFE747 /* PBXContainerItemProxy */; + }; 97B15C378163C5A05C822115 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = ADAD84AB61B493DA1D22B54C /* TermindInfrastructure */; @@ -1103,6 +1502,11 @@ target = B243FDB536F5111B356353DA /* TermindDomain */; targetProxy = DB8AB3CC3053A07800C7EFAE /* PBXContainerItemProxy */; }; + E954DDEB2251D5DDF5B5E4D0 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = BDA6B93652DE90AEBDA7090E /* TermindRemoteInfrastructure */; + targetProxy = 81596FC81A193992EB68BB60 /* PBXContainerItemProxy */; + }; EA931847718F5BD9BCEBB63A /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B243FDB536F5111B356353DA /* TermindDomain */; @@ -1131,6 +1535,27 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + 00A459BF9EBF49731B530B7A /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = F6SUNDBBN9; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACH_O_TYPE = staticlib; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind.termindremoteinfrastructure; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INSTALL_OBJC_HEADER = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 171123A06EEDC2EE73AD3FC8 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1224,9 +1649,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TFI/Termind.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = F6SUNDBBN9; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TFI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Termind; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1272,6 +1699,27 @@ }; name = Debug; }; + 51206A2D54AE88E0247CD9C6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = F6SUNDBBN9; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACH_O_TYPE = staticlib; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind.termindpresentation; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INSTALL_OBJC_HEADER = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; 56B29D693660C76F4121622D /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1295,6 +1743,55 @@ }; name = Release; }; + 61A9E8DD25722DF762DBC92A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = F6SUNDBBN9; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACH_O_TYPE = staticlib; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind.termindremoteinfrastructure; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INSTALL_OBJC_HEADER = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 67308C9998F6AA19BBC68898 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = F6SUNDBBN9; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TFIWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.6.0; + PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind.workspaces; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; 925DF91139ED491057366958 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1337,6 +1834,27 @@ }; name = Release; }; + A1DAF8C420A41777983ECE89 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + DEFINES_MODULE = YES; + DEVELOPMENT_TEAM = F6SUNDBBN9; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + MACH_O_TYPE = staticlib; + MACOSX_DEPLOYMENT_TARGET = 15.0; + PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind.termindpresentation; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "macosx iphoneos iphonesimulator"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_INSTALL_OBJC_HEADER = YES; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; BD9E47297AE0FA3235A4633E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1384,9 +1902,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = TFI/Termind.entitlements; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_TEAM = F6SUNDBBN9; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TFI/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = Termind; INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -1409,6 +1929,34 @@ }; name = Release; }; + D9AB222931EA3C8F960F429A /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + APPLICATION_EXTENSION_API_ONLY = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = F6SUNDBBN9; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = TFIWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = "Termind Workspaces"; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.6.0; + PRODUCT_BUNDLE_IDENTIFIER = com.akinokaede.termind.workspaces; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; E072A089DE54B923933916DA /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -1688,6 +2236,24 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 12F5D040E2844B6FD884BF65 /* Build configuration list for PBXNativeTarget "TermindRemoteInfrastructure" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 61A9E8DD25722DF762DBC92A /* Debug */, + 00A459BF9EBF49731B530B7A /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A55575DB0A0A07D5A5B38BE /* Build configuration list for PBXNativeTarget "TermindWidgets" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + D9AB222931EA3C8F960F429A /* Debug */, + 67308C9998F6AA19BBC68898 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 20CE6E8A93D697985F372EA6 /* Build configuration list for PBXNativeTarget "TFI" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -1697,6 +2263,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 5187A7B8CCE63A51982AB904 /* Build configuration list for PBXNativeTarget "TermindPresentation" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 51206A2D54AE88E0247CD9C6 /* Debug */, + A1DAF8C420A41777983ECE89 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 5658F2D4BC8AAEC899C2300F /* Build configuration list for PBXNativeTarget "TermindInfrastructure" */ = { isa = XCConfigurationList; buildConfigurations = (