Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
73f7080
moriremote: transplant remux terminal core
vaayne Aug 1, 2026
24471ec
moriremote: cut over to remux terminal runtime
vaayne Aug 1, 2026
5a6d229
moriremote: verify adaptive release shell
vaayne Aug 1, 2026
ae1d4d9
moriremote: expand terminal shortcut bar
vaayne Aug 1, 2026
09bae55
moriremote: give shortcut row full width
vaayne Aug 1, 2026
564b949
moriremote: keep static terminal out of app bundle
vaayne Aug 1, 2026
496fcd0
moriremote: discover tmux sessions by server
vaayne Aug 1, 2026
d4b4293
moriremote: restore compact terminal menus
vaayne Aug 1, 2026
3239945
moriremote: expand shortcut menu and terminal viewport
vaayne Aug 1, 2026
e1f503f
moriremote: switch among host tmux sessions
vaayne Aug 1, 2026
361a26e
moriremote: unify terminal navigation and keypad
vaayne Aug 1, 2026
a428267
moriremote: clarify host navigation and input
vaayne Aug 1, 2026
f04fe38
moriremote: stabilize metadata projection tests
vaayne Aug 1, 2026
2dfb411
moriremote: restore IME and image input
vaayne Aug 1, 2026
3bab847
moriremote: refresh scrollback after rendered frames
vaayne Aug 1, 2026
9bbc11f
moriremote: remove superseded pane previews
vaayne Aug 1, 2026
2f486d9
moriremote: collapse terminal transplant scaffolding
vaayne Aug 1, 2026
86cd3a8
moriremote: observe workspace runtime state directly
vaayne Aug 1, 2026
fe4632c
moriremote: centralize authenticated SSH roots
vaayne Aug 1, 2026
9c657e8
moriremote: remove manual workspace persistence paths
vaayne Aug 1, 2026
92043ac
moriremote: keep terminal closure policy at transport boundary
vaayne Aug 1, 2026
c5a5d0a
moriremote: remove unused terminal projection domains
vaayne Aug 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### ✨ Features

- **iOS (MoriRemote)**: Rebuilt the remote companion around a secure, pane-native tmux control runtime. It supports saved workspaces, password or OpenSSH private-key authentication, explicit host-key trust, local terminal history/selection, agent status, and adaptive iPhone/iPad presentation without taking over another attached tmux client's selection or size.
- **iOS (MoriRemote)**: Reworked terminal navigation and input chrome: a slim four-icon keyboard accessory replaces the oversized capsule dock; Navigator has an explicit host selector and switches terminals only after choosing a session; modal forms suspend the terminal responder; Chinese and other IMEs now commit marked text exactly once. Keypad also adds photo/clipboard image input through the current authenticated SSH root: MoriRemote previews and atomically uploads the image, then inserts a shell-escaped remote path without pressing Enter.
- **iOS (MoriRemote)**: Server profiles now discover their live tmux sessions after SSH login, so users choose a session instead of manually creating workspace records. The terminal’s Sessions button refreshes and lists every tmux session on the current host, including sessions not yet connected on the phone.

### 🐛 Bug Fixes

- **iOS (MoriRemote)**: Make workspace runtime status, topology, and agent metadata directly observable, so an open Navigator and library badges update instead of writing to an unused revision counter.
- **iOS (MoriRemote)**: Refresh local scrollback geometry after Ghostty publishes each completed renderer frame, so output arriving after the pre-render terminal-change callback no longer leaves a stale hard stop above the true bottom.
- **iOS (MoriRemote)**: Prevented a usable terminal from remaining labeled “Connecting…” when a delayed syncing callback arrives after live topology.
- **iOS (MoriRemote)**: Fixed SSH tmux connections remaining on “Waiting for the active tmux pane” even though the remote control client had attached.
- **iOS (MoriRemote)**: Hardened credentials to device-bound, unlocked-only Keychain storage; fenced Ghostty shutdown behind terminal-surface teardown; defer reconnects while backgrounded; and release dormant runtimes first under memory pressure.

Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.zh-Hans.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@
### ✨ 新功能

- **iOS(MoriRemote)**:远程伴侣已重构为安全、以 pane 为原生单位的 tmux 控制运行时。支持保存工作区、密码或 OpenSSH 私钥认证、显式主机密钥信任、本地终端历史/选择、agent 状态,以及自适应 iPhone/iPad 界面;不会接管其他已连接 tmux 客户端的选择或尺寸。
- **iOS(MoriRemote)**:重做终端导航与输入栏:纤薄的四图标键盘附件取代笨重的胶囊 Dock;Navigator 提供明确的主机选择器,只有选择会话后才切换终端;模态表单会暂停终端响应器;中文等输入法的组合文本只会提交一次。Keypad 还新增照片与剪贴板图片输入:图片经当前已认证 SSH 根连接预览并原子上传,随后仅插入经过 shell 转义的远程路径,不会自动按回车。
- **iOS(MoriRemote)**:服务器配置现在会在 SSH 登录后自动发现实时 tmux 会话,用户直接选择,不再需要手动创建工作区记录。终端的 Sessions 按钮会刷新并列出当前主机上的全部 tmux 会话,包括手机尚未连接的会话。

### 🐛 问题修复

- **iOS(MoriRemote)**:让工作区运行状态、拓扑和 agent 元数据可被直接观察,避免 Navigator 与资源库徽章只写入无人读取的 revision 而不更新。
- **iOS(MoriRemote)**:在 Ghostty 发布每个完整渲染帧后刷新本地回滚区几何,避免预渲染终端变更回调读到旧状态,导致滚动在真实底部之前被硬性截断。
- **iOS(MoriRemote)**:修复终端已可用后,延迟到达的同步回调仍会让标题一直显示“正在连接”的问题。
- **iOS(MoriRemote)**:修复远端 tmux 控制客户端已经连接,但界面仍一直停在“正在等待活动的 tmux pane”的问题。
- **iOS(MoriRemote)**:凭证改为仅限本设备、仅在解锁时可用的 Keychain 存储;Ghostty 必须在终端 surface 拆除后才释放;后台期间延后重连;低内存时优先释放非活动运行时。

Expand Down
618 changes: 567 additions & 51 deletions MoriRemote/MoriRemote.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,17 @@
ReferencedContainer = "container:MoriRemote.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "CC5FEE20D68E195A5B4E8935"
BuildableName = "MoriRemoteTerminalTests.xctest"
BlueprintName = "MoriRemoteTerminalTests"
ReferencedContainer = "container:MoriRemote.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<CommandLineArguments>
</CommandLineArguments>
Expand Down
85 changes: 34 additions & 51 deletions MoriRemote/MoriRemote/App/MoriRemoteDependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import Foundation
@MainActor
final class MoriRemoteDependencies {
let library: RemoteLibrary
let trustedHosts: TrustedHostStore
let roots = SSHRootPool()
private var ghosttyRuntime: GhosttyKitRuntime?
let sshRoots: SSHRootAccess

init(storage: MoriRemoteStorage, legacyServersURL: URL) {
trustedHosts = storage.trustedHosts
let migrator = LegacyServerMigrator(storage: storage, legacyServersURL: legacyServersURL)
library = RemoteLibrary(storage: storage, migrator: migrator)
let library = RemoteLibrary(storage: storage, migrator: migrator)
self.library = library
sshRoots = SSHRootAccess(
library: library,
pool: SSHRootPool(),
trustedHosts: storage.trustedHosts
)
}

static func live() -> MoriRemoteDependencies {
Expand All @@ -26,12 +29,6 @@ final class MoriRemoteDependencies {
}
}

func terminalRuntime() throws -> GhosttyKitRuntime {
if let ghosttyRuntime { return ghosttyRuntime }
let runtime = try GhosttyKitRuntime()
ghosttyRuntime = runtime
return runtime
}
}

struct RemoteLibrarySnapshot: Sendable {
Expand Down Expand Up @@ -74,24 +71,16 @@ actor RemoteLibrary {

func reload() throws -> RemoteLibrarySnapshot { try snapshot(migration: nil) }

func save(server: SavedServer, workspace: SavedWorkspace?, identity: SSHIdentity, credential: ProfileCredential?) throws -> RemoteLibrarySnapshot {
func save(server: SavedServer, identity: SSHIdentity, credential: ProfileCredential?) throws -> RemoteLibrarySnapshot {
var server = try server.validated()
var workspace = try workspace?.validated()
_ = try identity.validated()
guard (workspace == nil || workspace?.serverID == server.id), identity.serverID == server.id, identity.id == server.identityID else {
guard identity.serverID == server.id, identity.id == server.identityID else {
throw PersistenceError.corruptStore("profile references")
}
let existingServers = try storage.servers.all()
let existingWorkspaces = try storage.workspaces.all()
// Drafts never own recency. Preserve it through profile edits so an edit
// cannot reorder a server/workspace or revive a different workspace.
// cannot reorder a server or its discovered sessions.
if let existing = existingServers.first(where: { $0.id == server.id }) { server.lastConnectedAt = existing.lastConnectedAt }
if let id = workspace?.id, let existing = existingWorkspaces.first(where: { $0.id == id }) {
// A profile edit may update only its own selected workspace; it may
// never repurpose another server's workspace record.
guard existing.serverID == server.id else { throw PersistenceError.corruptStore("workspace ownership") }
workspace?.lastConnectedAt = existing.lastConnectedAt
}
let existingIdentity = try storage.identities.all().first { $0.id == identity.id }
if let existingIdentity, existingIdentity.kind != identity.kind, credential == nil {
// A changed identity type must never silently reinterpret a secret.
Expand All @@ -111,13 +100,6 @@ actor RemoteLibrary {
} else {
_ = try storage.servers.insertIfAbsent(server)
}
if let workspace {
if existingWorkspaces.contains(where: { $0.id == workspace.id }) {
try storage.workspaces.replace(workspace)
} else {
_ = try storage.workspaces.insertIfAbsent(workspace)
}
}
if try storage.identities.all().contains(where: { $0.id == identity.id }) {
try storage.identities.replace(identity)
} else {
Expand Down Expand Up @@ -151,28 +133,6 @@ actor RemoteLibrary {
return try snapshot(migration: nil)
}

func save(workspace: SavedWorkspace) throws -> RemoteLibrarySnapshot {
var workspace = try workspace.validated()
guard try storage.servers.all().contains(where: { $0.id == workspace.serverID }) else {
throw PersistenceError.notFound(workspace.serverID)
}
let existingWorkspaces = try storage.workspaces.all()
if let existing = existingWorkspaces.first(where: { $0.id == workspace.id }) {
guard existing.serverID == workspace.serverID else { throw PersistenceError.corruptStore("workspace ownership") }
// User edits name/session, never their recency ordering.
workspace.lastConnectedAt = existing.lastConnectedAt
try storage.workspaces.replace(workspace)
} else {
_ = try storage.workspaces.insertIfAbsent(workspace)
}
return try snapshot(migration: nil)
}

func delete(workspaceID: UUID) throws -> RemoteLibrarySnapshot {
try storage.workspaces.remove(workspaceID)
return try snapshot(migration: nil)
}

func delete(serverID: UUID) throws -> RemoteLibrarySnapshot {
let workspaces = try storage.workspaces.all().filter { $0.serverID == serverID }
let identities = try storage.identities.all().filter { $0.serverID == serverID }
Expand All @@ -196,6 +156,29 @@ actor RemoteLibrary {
try SSHHostTrustResolver(store: storage.trustedHosts).explicitlyTrust(challenge, replaceChanged: replaceChanged)
}

func synchronizeDiscoveredSessions(serverID: UUID, names: [String]) throws -> RemoteLibrarySnapshot {
guard try storage.servers.all().contains(where: { $0.id == serverID }) else {
throw PersistenceError.notFound(serverID)
}
var existingNames = Set(try storage.workspaces.all().filter { $0.serverID == serverID }.map(\.tmuxSession))
for name in names where !existingNames.contains(name) {
let workspace = try SavedWorkspace(serverID: serverID, name: name, tmuxSession: name).validated()
_ = try storage.workspaces.insertIfAbsent(workspace)
existingNames.insert(name)
}
return try snapshot(migration: nil)
}

func discoveryMaterial(for serverID: UUID) throws -> (SavedServer, SSHIdentity, RemoteSettings) {
guard let server = try storage.servers.all().first(where: { $0.id == serverID }) else {
throw PersistenceError.notFound(serverID)
}
guard let identity = try storage.identities.all().first(where: { $0.id == server.identityID }) else {
throw SSHAuthResolverError.missingIdentity(server.identityID)
}
return (server, identity, try storage.settings.load(or: .default))
}

func connectionMaterial(for workspaceID: UUID) throws -> (SavedWorkspace, SavedServer, SSHIdentity, RemoteSettings) {
let workspaces = try storage.workspaces.all()
guard let workspace = workspaces.first(where: { $0.id == workspaceID }) else { throw PersistenceError.notFound(workspaceID) }
Expand Down
Loading
Loading