Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 11 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,27 +70,31 @@ Tab、导航和连接会话协调在 `app.svelte.ts`;AI、主题、快捷键
rg '^let _.*\$state|^export function' src/lib/stores src/lib/ai src/lib/themes
```

### R9. 平台条件统一走 `cfg` / `app.isMobile`
### R9. 布局、输入方式与宿主能力分别判断

OS / 设备形态分支:Rust 端用 `#[cfg(...)]`,前端用 `app.isMobile`(UA 嗅探,顶层 const),不要在各组件重复造一套判断。Docker CLI、kubectl、keychain 等外部能力是否可用,仍应在运行时真实探测。
页面排版只看窗口或容器宽度:CSS 用 media/container query,需要切换 DOM 时用 `layout.compact()`;不能用 UA、OS 或触摸能力选择手机/电脑布局。触摸、鼠标、键盘按实际输入事件处理,窄窗口不代表没有硬件键盘,宽窗口也可能需要软键盘。

原生功能必须走 `app.capabilities()`(后端 `get_runtime_capabilities`),不能从宽度推断 PTY、串口、多窗口、目录授权等能力。Rust 只在平台 API、库或实现不同处使用 `#[cfg(...)]`。Rust 源码统一使用 `linux` / `macos` / `windows` / `android` / `ios` 五个互斥条件(`windows` 为 Rust 内建,其余在 build.rs 定义);不使用 Tauri 的 `mobile` / `desktop` 分类。多系统实现显式列举,例如 `any(android, ios)`。Cargo.toml 依赖条件仍使用原生 target 表达式(build.rs 的 cfg 不参与依赖解析)。App 成功加载能力后才挂载资源页面。Docker CLI、kubectl、keychain 等外部能力仍应在运行时真实探测。

```bash
rg 'cfg\(target_os|isMobile' src src-tauri/src
rg 'layout\.compact|@media|@container|capabilities\(' src
cat src-tauri/build.rs # 五个系统条件的唯一定义
rg 'cfg\(|cfg!\(' src-tauri/src
```

### R10. 新增功能必须显式考虑四条入口

每个新 feature / UI 改动,PR 描述里至少写清以下入口各自怎么处理:

- **桌面 GUI**:默认目标,必须可用
- **移动 GUI**:`app.isMobile` 路径。没右键、没快捷键、没多窗口、屏幕窄。要么适配(`MobileKeybar` 加按钮、长按代替右键),要么显式声明"移动端不提供"
- **移动 GUI**:窄窗口与触摸输入都要可操作,同时保留外接键盘/鼠标的能力;宽屏平板使用宽布局。操作需有可点击入口;确实缺少原生 API 的功能由能力查询关闭,不能按设备名称一概隐藏。
- **CLI**:`src-tauri/src/bin/rssh/`。CRUD 类操作大概率要补;纯 UI/可视化类可声明 N/A
- **Headless / JetBrains**:`src-tauri/src/server.rs` + `src/lib/ipc-shim.ts`。复用同一前端,但 command / event 需要 server adapter 支持

允许的结论是“全部支持”或“只在 X 入口,因为 Y”。**不允许**的是没想过——上线后才发现移动端按钮够不着、CLI 改了 schema 但读不出新字段、JetBrains 页面只能报 unknown command。

```bash
rg 'isMobile' src/lib/components # 看现有移动端分支怎么写
rg 'layout\.compact|@media|@container' src/lib/components # 看宽度响应式布局
rg '#\[cfg\(' src-tauri/src/commands # 看 command 层平台分支
rg '"[a-z_]+" =>' src-tauri/src/server.rs # 看 headless dispatcher
```
Expand Down Expand Up @@ -244,9 +248,9 @@ rg 'db::|secret_store|sync::config' src-tauri/src/bin/rssh

本地 export 是全量备份;远程 `rssh config github push` / `rssh config webdav push` 与 GUI push 才按同步类别、分组及 secret opt-in 过滤。Credential 用 `save_to_remote`,Telnet 登录脚本用 `save_script_to_remote`。改同步逻辑必须统一走 `sync::config::build_payload`,不能让 GUI / CLI / GitHub / WebDAV 四条路径各写一份规则。

### P7. `isMobile` 是 const,不响应窗口缩放
### P7. 布局断点不能决定输入或原生能力

UA 嗅探,启动一次。需要响应式断点 → 自己加 `$state` + `resize` 监听,别误以为现成。
`stores/layout.svelte.ts` 统一观察窗口宽度,App 根组件管理监听生命周期。侧栏保留窄/宽两份偏好;resize 只切换读取,不改持久化设置。终端辅助键盘(包括唤起系统键盘的按钮)只在窄屏显示;宽屏不显示辅助键盘或独立浮钮,不按 OS、触摸能力或系统键盘是否打开增加例外。硬件键盘与系统输入法的输入处理独立于断点。

### P8. Command 名是跨适配器 wire contract

Expand Down
1 change: 1 addition & 0 deletions idea-plugin/src/main/kotlin/sh/rssh/idea/RsshBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ object RsshBridge {
)}
});
};
window.dispatchEvent(new Event('rssh:host-ready'));
""".trimIndent()

browser.jbCefClient.addLoadHandler(
Expand Down
20 changes: 0 additions & 20 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,6 @@
"@codemirror/legacy-modes": "^6.5.2",
"@codemirror/theme-one-dark": "^6.1.3",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-fs": "^2",
"@types/dompurify": "^3.0.5",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-image": "^0.9.0",
Expand Down
3 changes: 3 additions & 0 deletions roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
- 移动端增加自定义键盘
- profile/forward 自定义icon
- iOS快捷键盘,随着软键盘升起
- 滚动到上一屏,按cmd会回到光标处,不好复制
- 折叠的块,也要支持复制文本
- 快捷命令块,增加占位符


![Stars](https://img.shields.io/github/stars/rssh-org/rssh)
Expand Down
10 changes: 6 additions & 4 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@ tree-sitter-bash = "0.25.1"
fontdb = "0.23.0"
url = "2.5.8"

# Mobile reqwest carries webpki-roots so TLS works without a probeable
# system trust store.
[target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies]
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots", "rustls-tls-webpki-roots", "stream", "system-proxy"] }

Expand All @@ -88,16 +90,16 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
[target.'cfg(any(target_os = "macos", target_os = "linux"))'.dependencies]
libc = "0.2"

# PTY, serial and clipboard — the three desktop targets RSSH actually ships.
# Cargo target tables cannot use Tauri's `desktop` cfg alias, so keep this as a
# positive OS list instead of treating every non-mobile target as a desktop.
# Native desktop PTY, serial and clipboard retain their existing backends.
# Cargo target tables cannot use the application's build.rs OS aliases, so keep
# the native target conditions here.
[target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies]
portable-pty = "0.8"
# Serial console transport — a 3rd byte-stream alongside SSH/PTY. macOS uses
# IOKit and Windows uses the Win32 API (no extra system dep); Linux links
# libudev for port enumeration, so Linux CI/packaging needs `libudev-dev` at
# build time (see build-linux.sh).
serialport = "4.9.0"
portable-pty = "0.8"
arboard = "3"

# Keychain — Apple platforms share the native Security.framework backend.
Expand Down
24 changes: 24 additions & 0 deletions src-tauri/build.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,27 @@
fn main() {
// Application OS names are mutually exclusive. Reject unsupported runtimes
// before selecting a native backend from their shared target OS.
// Read the compilation target, not the host running this build script.
// `windows` already exists as a built-in Rust cfg.
for os in ["linux", "macos", "android", "ios"] {
println!("cargo:rustc-check-cfg=cfg({os})");
}
let target_os = std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo target OS");
let target_env = std::env::var("CARGO_CFG_TARGET_ENV").expect("Cargo target environment");
let os = match (target_os.as_str(), target_env.as_str()) {
("linux", "ohos") => {
panic!("RSSH does not support target OS {target_os} with environment {target_env}")
}
("linux", _) => "linux",
("macos", _) => "macos",
("windows", _) => "windows",
("android", _) => "android",
("ios", _) => "ios",
_ => panic!("RSSH does not support target OS {target_os} with environment {target_env}"),
};
if os != "windows" {
println!("cargo:rustc-cfg={os}");
}

tauri_build::build()
}
9 changes: 3 additions & 6 deletions src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
{
"identifier": "default",
"description": "Default capabilities for RSSH",
"description": "Core capabilities for RSSH",
"windows": ["main", "rssh-*"],
"permissions": [
"core:default",
"core:window:allow-set-title",
"core:window:allow-set-always-on-top",
"opener:default",
"dialog:default",
"fs:default",
"fs:allow-write-text-file"
"core:window:allow-set-decorations",
"core:window:allow-set-always-on-top"
]
}
34 changes: 19 additions & 15 deletions src-tauri/src/ai/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -476,7 +476,7 @@ pub async fn ai_session_start_impl(
}
Some(handle)
}
#[cfg(desktop)]
#[cfg(any(windows, macos, linux))]
AiTarget::Local(target_id) => {
let shell_path = match crate::commands::lifecycle::owned_ready_ai_target(
state,
Expand All @@ -490,9 +490,9 @@ pub async fn ai_session_start_impl(
initial_shell = super::shell::ShellKind::from_local_path(&shell_path);
None
}
#[cfg(mobile)]
#[cfg(any(android, ios))]
AiTarget::Local(_) => return Err(AppError::not_found("local_pty_not_found", json!({}))),
#[cfg(desktop)]
#[cfg(any(windows, macos, linux))]
AiTarget::Serial(target_id) => {
// No shell to probe — a serial port is raw bytes. Validate it exists,
// then run with ShellKind::Serial (no sentinel, no exit code) and no
Expand All @@ -506,7 +506,7 @@ pub async fn ai_session_start_impl(
initial_shell = super::shell::ShellKind::Serial;
None
}
#[cfg(mobile)]
#[cfg(any(android, ios))]
AiTarget::Serial(_) => {
return Err(AppError::not_found("serial_session_not_found", json!({})))
}
Expand Down Expand Up @@ -535,11 +535,15 @@ pub async fn ai_session_start_impl(
// 用户写多个 skill 也不会让启动 prompt 爆炸。
let _ = skill; // 前端不再选;保留参数兼容
let locale_lbl = locale_label(locale.as_deref().unwrap_or("en"));
// 移动端注入能力声明,引导 LLM 切桌面端、别徒劳调工具:analyze_locally 真·阻断
// (Tauri 2 mobile 不能 spawn 分析窗口);download_file 技术上能跑(写 app 数据
// 目录),但 analyze_locally 用不了、下下来的文件也取不出私有目录,故一并劝退。
let is_mobile = cfg!(mobile);
let system_prompt = skills::build_catalog_prompt(&state.db, locale_lbl, is_mobile)?;
// Use the same host check as analyze_locally so the prompt reflects
// whether this runtime can open a local analysis window.
let local_analysis_unavailable = host.ensure_local_analysis_available().await.err();
let system_prompt = skills::build_catalog_prompt(
&state.db,
locale_lbl,
local_analysis_unavailable.as_deref(),
host.allows_diagnosis_downloads(),
)?;
let user_skills_cache = skills::list_user(&state.db)?;

// 3. Conversation identity. Resume revives a persisted history under its
Expand Down Expand Up @@ -1156,7 +1160,7 @@ pub(crate) fn ai_session_rebind_target_impl(
crate::commands::lifecycle::OwnedAiTarget::Ssh { handle, .. } => Some(handle),
_ => unreachable!("SSH lifecycle kind returned a non-SSH target"),
},
#[cfg(desktop)]
#[cfg(any(windows, macos, linux))]
AiTarget::Local(target_id) => {
let _ = crate::commands::lifecycle::owned_ready_ai_target(
state,
Expand All @@ -1166,9 +1170,9 @@ pub(crate) fn ai_session_rebind_target_impl(
)?;
None
}
#[cfg(mobile)]
#[cfg(any(android, ios))]
AiTarget::Local(_) => return Err(AppError::not_found("local_pty_not_found", json!({}))),
#[cfg(desktop)]
#[cfg(any(windows, macos, linux))]
AiTarget::Serial(target_id) => {
let _ = crate::commands::lifecycle::owned_ready_ai_target(
state,
Expand All @@ -1178,7 +1182,7 @@ pub(crate) fn ai_session_rebind_target_impl(
)?;
None
}
#[cfg(mobile)]
#[cfg(any(android, ios))]
AiTarget::Serial(_) => {
return Err(AppError::not_found("serial_session_not_found", json!({})))
}
Expand Down Expand Up @@ -1344,15 +1348,15 @@ pub(crate) fn conversation_target_key(state: &AppState, target: &AiTarget) -> Ap
crate::db::ai_conversation::ssh_target_key(h.profile_id())
}
AiTarget::Local(_) => "local".to_string(),
#[cfg(desktop)]
#[cfg(any(windows, macos, linux))]
AiTarget::Serial(id) => {
let g = locked(&state.serial_sessions)?;
let h = g
.get(id)
.ok_or_else(|| AppError::not_found("serial_session_not_found", json!({})))?;
format!("serial:{}", h.port_name())
}
#[cfg(mobile)]
#[cfg(any(android, ios))]
AiTarget::Serial(_) => {
return Err(AppError::not_found("serial_session_not_found", json!({})))
}
Expand Down
Loading
Loading