在线升级与外部 CLI 的文案接入多语言

- 在线升级:状态行、标题栏按钮、更新对话框、出错原因和「重启会关闭…」改走 t! / tn!,列表用 join_list
- 外部 CLI:命令输出(表头、字段、摘要、进度、错误)、应用回给命令的消息和校验、设置页的 Agent 名称都有中英两版
- --help 与 SKILL.md 提到的界面名称写英文并附中文
- 新增 locales/update.yml、locales/cli.yml,补英文输出的单元测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-07 22:05:37 +08:00
1 parent 17dc087f52
commit f6286b776c
20 files changed
+1398 -380

No files matched your search

+392
View File
@@ -0,0 +1,392 @@
_version: 2
# 外部 CLI: what the `shellrs` command prints, and the messages the app
# sends back to it. CLI output uses plain ASCII quotes and apostrophes, so
# it copies into a shell unchanged; the texts shown in the app's window
# (the settings page, notifications) use “ ” and ’ like the rest of the UI.
# The command could not reach the app.
cli.client.not_running:
zh-CN: ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」
en: ShellRS isn't running. Open ShellRS and turn on Enable external CLI in Settings → External CLI.
cli.client.broken:
zh-CN: 与 ShellRS 的连接意外中断:%{error}
en: "The connection to ShellRS broke off: %{error}"
cli.client.no_permission:
zh-CN: 没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信
en: No permission to connect to ShellRS. The agent may be running in a sandbox; allow it local interprocess communication.
cli.client.foreign_pipe:
zh-CN: 外部 CLI 的管道不属于当前用户,已拒绝连接
en: The external CLI's pipe belongs to another user, so the connection was refused.
cli.client.older_app:
zh-CN: 正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它
en: The running ShellRS doesn't know this command. Update ShellRS to the latest version, then restart it.
# Also the app's notification when the server cannot start.
cli.unsupported:
zh-CN: 此系统暂不支持外部 CLI
en: The external CLI is not supported on this system.
# Reading the command line and stdin.
cli.exec.empty:
zh-CN: 命令不能为空
en: The command is empty.
cli.exec.bad_json:
zh-CN: 标准输入不是 %{form} 形式的 JSON:%{error}
en: "The input on stdin isn't JSON of the form %{form}: %{error}"
cli.json.from_terminal:
zh-CN: 请从标准输入传入 JSON,例如 %{example}
en: "Pass the JSON on stdin, for example: %{example}"
cli.json.not_json:
zh-CN: 标准输入不是 JSON:%{error}
en: "The input on stdin isn't JSON: %{error}"
cli.json.invalid:
zh-CN: JSON 有误:%{error}
en: "The JSON is invalid: %{error}"
cli.stdin.unreadable:
zh-CN: 无法读取标准输入:%{error}
en: "Couldn't read stdin: %{error}"
cli.path.unresolvable:
zh-CN: 无法解析路径 %{path}:%{error}
en: "Couldn't resolve the path %{path}: %{error}"
# `hosts list` and `credentials list`: the column headers, upper case in
# English as command line tables have them.
cli.column.id:
zh-CN: ID
en: ID
cli.column.name:
zh-CN: 名称
en: NAME
cli.column.group:
zh-CN: 分组
en: GROUP
cli.column.address:
zh-CN: 地址
en: ADDRESS
cli.column.os:
zh-CN: 系统
en: SYSTEM
cli.column.kind:
zh-CN: 类型
en: TYPE
cli.column.user:
zh-CN: 用户名
en: USERNAME
cli.column.hosts:
zh-CN: 主机数
en: HOSTS
cli.hosts.none:
zh-CN: 没有匹配的主机
en: No matching hosts
cli.credentials.none:
zh-CN: 没有匹配的凭据
en: No matching credentials
# The group of a host connected to without saving.
cli.group.not_saved:
zh-CN: (未保存)
en: (not saved)
# `hosts show` and `credentials show`: the labels, then the values.
cli.field.id:
zh-CN: ID
en: ID
cli.field.name:
zh-CN: 名称
en: Name
cli.field.group:
zh-CN: 分组
en: Group
cli.field.address:
zh-CN: 地址
en: Address
cli.field.auth:
zh-CN: 认证
en: Authentication
cli.field.route:
zh-CN: 连接方式
en: Connection
cli.field.os:
zh-CN: 系统
en: System
cli.field.notes:
zh-CN: 备注
en: Notes
cli.field.kind:
zh-CN: 类型
en: Type
cli.field.user:
zh-CN: 用户名
en: Username
cli.field.private_key:
zh-CN: 私钥
en: Private key
cli.field.passphrase:
zh-CN: 口令
en: Passphrase
cli.field.hosts:
zh-CN: 主机
en: Hosts
cli.show.saved:
zh-CN: "%{label}(已保存)"
en: "%{label} (saved)"
cli.show.not_saved:
zh-CN: "%{label}(未保存)"
en: "%{label} (not saved)"
cli.auth.password:
zh-CN: 密码
en: Password
cli.auth.credential:
zh-CN: 凭据 %{id}
en: Credential %{id}
cli.auth.no_password:
zh-CN: 无密码
en: No password
cli.route.direct:
zh-CN: 直接连接
en: Direct
cli.route.jump:
zh-CN: SSH 跳板 %{hosts}
en: Jump hosts %{hosts}
cli.route.deleted_host:
zh-CN: (已删除的主机)
en: (deleted host)
cli.route.http:
zh-CN: HTTP 代理 %{address}
en: HTTP proxy %{address}
cli.route.socks5:
zh-CN: SOCKS5 代理 %{address}
en: SOCKS5 proxy %{address}
cli.route.password_saved:
zh-CN: "%{proxy}(密码已保存)"
en: "%{proxy} (password saved)"
cli.route.password_not_saved:
zh-CN: "%{proxy}(密码未保存)"
en: "%{proxy} (password not saved)"
cli.kind.password:
zh-CN: 密码
en: Password
cli.kind.key:
zh-CN: 密钥
en: Key
cli.kind.agent:
zh-CN: SSH Agent
en: SSH Agent
cli.credential.kept_key:
zh-CN: 由 ShellRS 保存
en: Kept by ShellRS
cli.credential.passphrase_saved:
zh-CN: 已保存
en: Saved
cli.credential.passphrase_not_saved:
zh-CN: 未保存
en: Not saved
# What `create`, `update` and `delete` did.
cli.host.created:
zh-CN: 已创建主机「%{name}」,ID %{id}
en: 'Created host "%{name}", ID %{id}'
cli.host.saved:
zh-CN: 已保存主机「%{name}」(ID %{id})
en: 'Saved host "%{name}" (ID %{id})'
cli.host.deleted:
zh-CN: 已删除主机「%{name}」
en: 'Deleted host "%{name}"'
cli.host.deleted_with_forwards.one:
en: 'Deleted host "%{name}" and its port forwarding rule'
cli.host.deleted_with_forwards.other:
zh-CN: 已删除主机「%{name}」,连同它的 %{count} 条端口转发规则
en: 'Deleted host "%{name}" and its %{count} port forwarding rules'
cli.host.deleted_jump_users.one:
en: "%{deleted}; 1 host jumped through it and now has a deleted jump host"
cli.host.deleted_jump_users.other:
zh-CN: "%{deleted};%{count} 台主机经它跳转,现在那一跳是「已删除的主机」"
en: "%{deleted}; %{count} hosts jumped through it and now have a deleted jump host"
cli.credential.created:
zh-CN: 已创建凭据「%{name}」,ID %{id}
en: 'Created credential "%{name}", ID %{id}'
cli.credential.saved:
zh-CN: 已保存凭据「%{name}」(ID %{id})
en: 'Saved credential "%{name}" (ID %{id})'
cli.credential.deleted:
zh-CN: 已删除凭据「%{name}」
en: 'Deleted credential "%{name}"'
cli.credential.deleted_released.one:
en: "%{deleted}; 1 host now logs in on its own: %{hosts}"
cli.credential.deleted_released.other:
zh-CN: "%{deleted};%{count} 台主机改为自己登录:%{hosts}"
en: "%{deleted}; %{count} hosts now log in on their own: %{hosts}"
# `upload`, `download` and `sync`: the progress line and the summary.
cli.progress:
zh-CN: "%{files}/%{total_files} 个文件,%{bytes} / %{total_bytes}"
en: "%{files}/%{total_files} files, %{bytes} / %{total_bytes}"
cli.summary.transferred.one:
en: Transferred 1 file (%{size})
cli.summary.transferred.other:
zh-CN: 已传输 %{count} 个文件,共 %{size}
en: Transferred %{count} files (%{size})
cli.summary.unchanged:
zh-CN: "%{summary},未变 %{count} 个"
en: "%{summary}, %{count} unchanged"
cli.summary.skipped:
zh-CN: "%{summary},跳过 %{count} 个"
en: "%{summary}, %{count} skipped"
cli.summary.deleted:
zh-CN: "%{summary},删除 %{count} 个"
en: "%{summary}, %{count} deleted"
cli.summary.failed:
zh-CN: "%{summary},失败 %{count} 个"
en: "%{summary}, %{count} failed"
# What the app answers when it will not take the request.
cli.server.version_mismatch:
zh-CN: shellrs 命令与正在运行的 ShellRS 版本不同:请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI
en: The shellrs command and the running ShellRS are different versions. Restart ShellRS, or update the CLI in Settings → External CLI.
cli.server.not_enabled:
zh-CN: ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」
en: The external CLI is turned off. Turn on Enable external CLI in ShellRS under Settings → External CLI.
cli.server.closed_during_change:
zh-CN: ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成
en: ShellRS closed while making the change. Open ShellRS again and check with shellrs hosts list whether it was made.
cli.server.change_timed_out:
zh-CN: ShellRS 没有及时处理这项改动,改动没有做:请稍后再试
en: ShellRS didn't get to the change in time, so it wasn't made. Try again later.
cli.server.not_absolute:
zh-CN: 本地路径必须是绝对路径:%{path}
en: "The local path must be absolute: %{path}"
# The app's notification when a second ShellRS starts.
cli.server.another_app:
zh-CN: 另一个 ShellRS 已在提供外部 CLI
en: Another ShellRS is already serving the external CLI.
cli.error.host_not_found:
zh-CN: 没有 ID 为 %{id} 的主机:请用 shellrs hosts list 查看
en: No host has the ID %{id}. List the hosts with shellrs hosts list.
cli.error.credential_not_found:
zh-CN: 没有 ID 为 %{id} 的凭据:请用 shellrs credentials list 查看
en: No credential has the ID %{id}. List the credentials with shellrs credentials list.
# Checking what `hosts create` / `update` ask for.
cli.host.temporary:
zh-CN: 「%{name}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可
en: '"%{name}" isn''t saved (it came from Quick connect or an external connection), so it can''t be changed or deleted. Closing its tab is enough.'
cli.host.credential_with_own_auth:
zh-CN: auth 为 password 或 no_password 时不能同时指定 credential
en: With auth password or no_password, credential can't be given.
cli.host.no_credential:
zh-CN: 请选择凭据
en: Choose a credential.
cli.host.credential_user:
zh-CN: 使用凭据的主机以凭据的用户名 %{user} 登录:去掉 user,或改凭据的用户名
en: A host using a credential logs in as the credential's user, %{user}. Leave out user, or change the credential's username.
cli.host.password_without_auth:
zh-CN: 只有 auth 为 password 的主机才保存自己的密码
en: Only a host with auth password keeps a password of its own.
cli.host.proxy_password_without_user:
zh-CN: 填写代理密码时请同时填写用户名
en: Enter a username along with the proxy password.
cli.host.jump_temporary:
zh-CN: 跳板只能是保存的主机:%{id} 是没有保存的连接
en: "Jump hosts must be saved hosts: %{id} isn't saved."
cli.host.jump_self:
zh-CN: 跳板主机不能是这台主机自己
en: A host can't be its own jump host.
cli.host.jump_repeated:
zh-CN: 跳板主机 %{id} 重复了
en: Jump host %{id} is listed twice.
cli.group.empty_level:
zh-CN: 分组路径「%{path}」里有空的一级
en: 'The group path "%{path}" has an empty level.'
cli.group.ambiguous:
zh-CN: 有 %{count} 个分组的路径都是「%{path}」:请先在 ShellRS 里给它们改名
en: '%{count} groups have the path "%{path}". Rename them in ShellRS first.'
# Checking what `credentials create` / `update` ask for.
cli.credential.key_without_kind:
zh-CN: 只有密钥凭据(kind 为 key)才有私钥
en: Only a key credential (kind key) has a private key.
cli.credential.password_without_kind:
zh-CN: 只有密码凭据(kind 为 password)才保存密码
en: Only a password credential (kind password) keeps a password.
cli.credential.passphrase_without_kind:
zh-CN: 只有密钥凭据(kind 为 key)才有口令
en: Only a key credential (kind key) has a passphrase.
cli.credential.two_keys:
zh-CN: key_path 和 private_key 只能给一个
en: Give key_path or private_key, not both.
cli.credential.key_not_absolute:
zh-CN: 私钥文件必须是绝对路径:%{path}
en: "The private key file must be an absolute path: %{path}"
cli.credential.key_missing:
zh-CN: 私钥文件不存在:%{path}
en: "The private key file doesn't exist: %{path}"
cli.credential.no_key_dir:
zh-CN: 没有可以保存私钥的目录
en: There's no folder to keep the private key in.
# Making the change in the app.
cli.change.host_gone:
zh-CN: 主机保存后不见了
en: The host was gone after saving.
cli.change.host_missing:
zh-CN: 主机已不存在
en: The host no longer exists.
cli.change.host_in_use:
zh-CN: 主机「%{name}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭
en: 'Host "%{name}" has tabs open. Close them first, or add --force to close them along with it.'
cli.change.credential_gone:
zh-CN: 凭据保存后不见了
en: The credential was gone after saving.
cli.change.key_not_saved:
zh-CN: 私钥未能保存:%{error}
en: "Couldn't save the private key: %{error}"
# One failure after another.
cli.change.failures:
zh-CN: "%{before};%{next}"
en: "%{before}; %{next}"
# Running commands and moving files.
cli.exec.signal:
zh-CN: 远程命令被信号 %{signal} 终止
en: The remote command was killed by signal %{signal}.
cli.transfer.sftp_ended:
zh-CN: SFTP 连接意外结束
en: The SFTP connection ended unexpectedly.
cli.transfer.no_local_folder:
zh-CN: 本地目录不存在:%{path}
en: "The local folder doesn't exist: %{path}"
cli.transfer.host_key_unknown:
zh-CN: 尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机
en: This host's key isn't trusted yet. Connect to the host once in ShellRS first.
cli.transfer.host_key_changed:
zh-CN: 主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份
en: The host key doesn't match the saved one, so the connection was refused. Check the server's identity first.
cli.transfer.missing_credential:
zh-CN: 这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码
en: No usable password or passphrase is saved for this host. Connect to it once in ShellRS and save the password.
cli.transfer.unfinished:
zh-CN: 传输未完成
en: The transfer didn't finish.
cli.transfer.empty_remote_path:
zh-CN: 远程路径不能为空
en: The remote path is empty.
# 设置 › 外部 CLI: the agents the skill installs for, and installing the
# command.
cli.agent.generic:
zh-CN: 通用 Agent
en: Any agent
cli.agent.generic_description:
zh-CN: 安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。
en: Installs to the standard Agent Skills folder, for agents that read it.
cli.install.unsupported:
zh-CN: 此系统暂不支持安装 shellrs 命令
en: Installing the shellrs command isn’t supported on this system.
cli.install.occupied:
zh-CN: "%{path} 已被其他程序占用,ShellRS 不会改动它"
en: "%{path} belongs to another program, so ShellRS leaves it alone"
cli.install.cancelled:
zh-CN: 已取消
en: Cancelled
cli.install.missing:
zh-CN: 找不到 %{path}
en: Can’t find %{path}
+257
View File
@@ -0,0 +1,257 @@
_version: 2
# 在线升级: the status line on 设置 › 关于, the title bar button, the update
# dialog and what goes wrong. Reasons that follow a colon start in lower
# case in English, like the system's own errors they stand beside.
# `UpdateError`, always shown after 「检查更新失败:」 and the like.
update.error.network:
zh-CN: 无法连接更新服务器:%{reason}
en: "couldn’t reach the update server: %{reason}"
update.error.http:
zh-CN: 更新服务器返回 HTTP %{status}
en: the update server answered HTTP %{status}
update.error.stalled:
zh-CN: 下载长时间没有进展,已中断
en: the download stalled and was stopped
update.error.bad_signature:
zh-CN: 更新清单的签名无效
en: the update manifest’s signature isn’t valid
update.error.bad_manifest:
zh-CN: 无法读取更新清单:%{reason}
en: "couldn’t read the update manifest: %{reason}"
update.error.corrupt:
zh-CN: 下载的文件与更新清单不符
en: the downloaded file doesn’t match the update manifest
update.error.no_space:
zh-CN: 磁盘空间不足
en: not enough disk space
update.error.disk:
zh-CN: 无法写入更新文件:%{reason}
en: "couldn’t write the update file: %{reason}"
update.error.install:
zh-CN: 无法安装新版本:%{reason}
en: "couldn’t install the new version: %{reason}"
update.error.cancelled:
zh-CN: 已取消
en: cancelled
# The reasons inside those errors.
update.feed.wrong_range:
zh-CN: 服务器返回的续传位置不对
en: the server resumed at the wrong position
update.feed.cut_off:
zh-CN: 连接在下载完成前断开
en: the connection closed before the download finished
update.feed.too_large:
zh-CN: 文件过大
en: the file is too large
update.feed.no_urls:
zh-CN: 没有下载地址
en: there’s no download address
update.feed.timed_out:
zh-CN: 连接超时
en: the connection timed out
update.updater.restart_failed:
zh-CN: 无法自动重新启动,请手动重新打开 ShellRS
en: ShellRS couldn’t restart itself, so open it again
update.updater.check_stopped:
zh-CN: 检查意外中止
en: the check stopped unexpectedly
update.install.not_possible:
zh-CN: 这份 ShellRS 无法自动更新
en: this copy of ShellRS can’t update itself
update.install.setup_missing:
zh-CN: 找不到下载的安装程序
en: the downloaded setup program is missing
update.install.tool_failed:
zh-CN: "%{program}:%{error}"
en: "%{program}: %{error}"
update.install.bad_signature:
zh-CN: 新版本的签名无效或不是 ShellRS 的开发者
en: the new version’s signature isn’t valid or isn’t from ShellRS’s developer
update.install.no_app:
zh-CN: 更新包里没有应用程序
en: the update package has no app in it
update.install.wrong_version:
zh-CN: 更新包的版本与更新清单不符
en: the update package’s version doesn’t match the update manifest
# Why this copy cannot replace itself: on 设置 › 关于 (with a full stop
# added) and in the update dialog.
update.unsupported.development_build:
zh-CN: 开发构建,不检查更新
en: Development builds don’t check for updates
update.unsupported.translocated:
zh-CN: ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开
en: ShellRS isn’t in the Applications folder, so it can’t update itself. Move it to Applications, then open it again
update.unsupported.disk_image:
zh-CN: ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹
en: ShellRS is running from its disk image, so it can’t update itself. Drag it to the Applications folder first
update.unsupported.read_only:
zh-CN: 没有权限写入 %{folder},无法自动更新
en: ShellRS can’t write to %{folder}, so it can’t update itself
update.unsupported.not_bundled:
zh-CN: ShellRS 不是从应用程序包运行的,无法自动更新
en: ShellRS isn’t running from an app bundle, so it can’t update itself
update.unsupported.not_installed:
zh-CN: 这份 ShellRS 不是用安装程序安装的,无法自动更新
en: This copy of ShellRS wasn’t installed by the setup program, so it can’t update itself
update.unsupported.not_appimage:
zh-CN: 这份 ShellRS 不是 AppImage,请用安装它的方式更新
en: This copy of ShellRS isn’t an AppImage. Update it the way you installed it
update.unsupported.platform:
zh-CN: ShellRS 没有为这个系统提供更新
en: ShellRS has no updates for this system
# The line under 当前版本 on 设置 › 关于.
update.status.unsupported:
zh-CN: "%{reason}。"
en: "%{reason}."
update.status.off:
zh-CN: 不检查更新。
en: Not checking for updates.
update.status.idle:
zh-CN: 尚未检查更新。
en: Not checked for updates yet.
update.status.checking:
zh-CN: 正在检查更新…
en: Checking for updates…
update.status.up_to_date:
zh-CN: 当前已是最新版本。
en: ShellRS is up to date.
update.status.available_manual:
zh-CN: 发现新版本 %{version},需要从官网下载安装。
en: ShellRS %{version} is available. Download it from the website to install it.
update.status.available_unsupported:
zh-CN: 发现新版本 %{version}。%{reason}。
en: ShellRS %{version} is available. %{reason}.
update.status.available:
zh-CN: 发现新版本 %{version}。
en: ShellRS %{version} is available.
update.status.downloading:
zh-CN: 正在下载新版本 %{version}… %{percent}%
en: Downloading ShellRS %{version}… %{percent}%
update.status.verifying:
zh-CN: 正在校验新版本 %{version}…
en: Verifying ShellRS %{version}…
update.status.ready:
zh-CN: 新版本 %{version} 已下载,重启 ShellRS 即可完成更新。
en: ShellRS %{version} is downloaded. Restart ShellRS to finish updating.
update.status.installing:
zh-CN: 正在安装新版本 %{version}…
en: Installing ShellRS %{version}…
update.status.check_failed:
zh-CN: 检查更新失败:%{error}
en: "Couldn’t check for updates: %{error}"
update.status.download_failed:
zh-CN: 下载新版本 %{version} 失败:%{error}
en: "Couldn’t download ShellRS %{version}: %{error}"
update.status.install_failed:
zh-CN: 安装新版本 %{version} 失败:%{error}
en: "Couldn’t install ShellRS %{version}: %{error}"
# The title bar button's tooltip.
update.badge.ready:
zh-CN: 新版本 %{version} 已就绪
en: ShellRS %{version} is ready to install
update.badge.available:
zh-CN: 新版本 %{version} 可用
en: ShellRS %{version} is available
update.badge.download_failed:
zh-CN: 新版本 %{version} 下载失败
en: Couldn’t download ShellRS %{version}
update.badge.install_failed:
zh-CN: 新版本 %{version} 安装失败
en: Couldn’t install ShellRS %{version}
# The update dialog.
update.dialog.title:
zh-CN: ShellRS 更新
en: ShellRS update
update.dialog.versions_released:
zh-CN: 当前版本 %{current} · 新版本发布于 %{day}
en: Current version %{current} · New version released %{day}
update.dialog.current_version:
zh-CN: 当前版本 %{current}
en: Current version %{current}
update.dialog.ready:
zh-CN: 新版本已下载,重启 ShellRS 即可完成更新。
en: The new version is downloaded. Restart ShellRS to finish updating.
update.dialog.downloading:
zh-CN: 正在下载 · %{percent}%
en: Downloading · %{percent}%
update.dialog.verifying:
zh-CN: 正在校验…
en: Verifying…
update.dialog.check_failed:
zh-CN: 检查失败:%{error}
en: "Couldn’t check for updates: %{error}"
update.dialog.download_failed:
zh-CN: 下载失败:%{error}
en: "Couldn’t download the update: %{error}"
update.dialog.install_failed:
zh-CN: 安装失败:%{error}
en: "Couldn’t install the update: %{error}"
update.dialog.manual:
zh-CN: 这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。
en: This version can’t be installed from within ShellRS. Download it from the website.
update.dialog.restart:
zh-CN: 重启并安装
en: Restart to update
update.dialog.download_page:
zh-CN: 前往下载页
en: Open download page
update.dialog.retry:
zh-CN: 重试
en: Retry
update.dialog.download:
zh-CN: 下载
en: Download
update.dialog.whats_new:
zh-CN: 查看更新内容
en: What’s new
# What a restart interrupts, in the update dialog above 重启并安装.
update.restart.remote_terminals.one:
en: 1 remote terminal
update.restart.remote_terminals.other:
zh-CN: "%{count} 个远程终端"
en: "%{count} remote terminals"
update.restart.sftp_tabs.one:
en: 1 SFTP tab
update.restart.sftp_tabs.other:
zh-CN: "%{count} 个 SFTP 标签"
en: "%{count} SFTP tabs"
update.restart.local_terminals.one:
en: 1 local terminal
update.restart.local_terminals.other:
zh-CN: "%{count} 个本地终端"
en: "%{count} local terminals"
update.restart.transfers.one:
en: 1 transfer (its progress is kept)
update.restart.transfers.other:
zh-CN: "%{count} 个传输(保留续传进度)"
en: "%{count} transfers (their progress is kept)"
update.restart.forwards.one:
en: 1 port forward
update.restart.forwards.other:
zh-CN: "%{count} 条端口转发"
en: "%{count} port forwards"
update.restart.closes:
zh-CN: 重启会关闭 %{closes}。
en: Restarting closes %{closes}.
update.restart.stops:
zh-CN: 重启会停止 %{stops}。
en: Restarting stops %{stops}.
update.restart.closes_and_stops:
zh-CN: 重启会关闭 %{closes},停止 %{stops}。
en: Restarting closes %{closes} and stops %{stops}.
update.restart.unsaved_files.one:
en: Unsaved changes in 1 file will be lost.
update.restart.unsaved_files.other:
zh-CN: "%{count} 个文件有未保存的修改,重启后会丢失。"
en: Unsaved changes in %{count} files will be lost.
update.restart.and_unsaved_files:
zh-CN: "%{note}%{unsaved}"
en: "%{note} %{unsaved}"
+2 -2
View File
@@ -21,7 +21,7 @@ shellrs download <host-id> <remote-path> <local-path>
shellrs sync <host-id> <local-dir> <remote-dir> [--delete]
```
`<host-id>` is the 16-character `id` from `shellrs hosts list` (the same ID that ShellRS copies with 复制 ID).
`<host-id>` is the 16-character `id` from `shellrs hosts list`, the same ID that ShellRS copies with Copy ID (复制 ID).
For a simple command, pass one complete remote shell command string as `<command>`.
Pass a command through stdin when it contains quotes, pipes, redirects, JSON, `$`, backticks, or nested shell code, and quote the heredoc delimiter so the local shell leaves it alone:
@@ -119,7 +119,7 @@ To create or update a host or credential from Windows PowerShell 5.1, pipe the J
Errors are printed to stderr with a code in brackets, such as `[not_enabled]`. Exit code 255 means `shellrs` could not run the command at all.
- `not_running`: ShellRS is not open. Ask the user to open ShellRS.
- `not_enabled`: ask the user to turn on 启用外部 CLI in ShellRS under 设置 → 外部 CLI.
- `not_enabled`: ask the user to turn on Enable external CLI (启用外部 CLI) in ShellRS under Settings → External CLI (设置 → 外部 CLI).
- `host_not_found`: run `shellrs hosts list` again and use an `id` from its output. `credential_not_found`: the same with `shellrs credentials list`.
- `bad_request` from `create` or `update`: the message says which field is wrong, in the words the ShellRS forms use.
- `host_in_use`: the host's tabs are open in ShellRS. Ask the user whether to close them (`--force`).
+12 -8
View File
@@ -10,6 +10,7 @@ use std::{
use super::protocol::{CliError, ErrorCode, TransferCounters, TransferSummary};
use super::server::{CliBackend, CliTarget};
use crate::connection::{ConnectionPromptKind, ConnectionPromptReply};
use crate::i18n::t;
use crate::secrets::{SecretRef, SharedSecretStore};
use crate::sftp::{
DownloadRequest, RemotePath, SftpCommand, SftpEvent, SharedSftpTransportProvider,
@@ -72,7 +73,7 @@ impl SshCliBackend {
Ok(SftpEvent::Prompt(prompt)) => run.refuse(&commands, &prompt),
Ok(SftpEvent::Disconnected(reason)) => return Err(run.failure(reason)),
Ok(_) => {}
Err(_) => return Err(run.failure("SFTP 连接意外结束".into())),
Err(_) => return Err(run.failure(t!("cli.transfer.sftp_ended").into())),
}
}
let _ = commands.send_blocking(command);
@@ -140,7 +141,7 @@ impl CliBackend for SshCliBackend {
Ok(ExecExit::Signal(signal)) => {
let _ = output(
ExecStream::Stderr,
format!("shellrs: 远程命令被信号 {signal} 终止\n").as_bytes(),
format!("shellrs: {}\n", t!("cli.exec.signal", signal = signal)).as_bytes(),
);
Ok(signal_exit_code(&signal))
}
@@ -190,7 +191,7 @@ impl CliBackend for SshCliBackend {
if !source.is_dir() {
return Err(CliError::new(
ErrorCode::BadRequest,
format!("本地目录不存在:{}", source.display()),
t!("cli.transfer.no_local_folder", path = source.display()),
));
}
let request = UploadRequest::sync(source.to_path_buf(), remote_path(destination)?, delete);
@@ -225,15 +226,15 @@ impl Transfer {
let refused = match prompt.kind() {
ConnectionPromptKind::UnknownHost(_) => CliError::new(
ErrorCode::HostKeyUnknown,
"尚未信任这台主机的密钥:请先在 ShellRS 中连接一次这台主机",
t!("cli.transfer.host_key_unknown"),
),
ConnectionPromptKind::HostKeyChanged(_) => CliError::new(
ErrorCode::HostKeyChanged,
"主机密钥与已保存的不一致,已拒绝连接。请先核实服务器身份",
t!("cli.transfer.host_key_changed"),
),
ConnectionPromptKind::Authentication(_) => CliError::new(
ErrorCode::MissingCredential,
"这台主机没有保存可用的密码或口令:请先在 ShellRS 中连接一次这台主机并保存密码",
t!("cli.transfer.missing_credential"),
),
};
self.refused.get_or_insert(refused);
@@ -264,7 +265,7 @@ impl Transfer {
ErrorCode::TransferFailed,
self.notice
.take()
.unwrap_or_else(|| "传输未完成".to_string()),
.unwrap_or_else(|| t!("cli.transfer.unfinished").to_string()),
)
})),
}
@@ -287,7 +288,10 @@ fn remote_path(path: &str) -> Result<RemotePath, CliError> {
let path = path.trim();
let path = match path {
"" => {
return Err(CliError::new(ErrorCode::BadRequest, "远程路径不能为空"));
return Err(CliError::new(
ErrorCode::BadRequest,
t!("cli.transfer.empty_remote_path"),
));
}
"~" => ".".to_string(),
_ if path.starts_with("~/") => format!(".{}", &path[1..]),
+325 -124
View File
@@ -8,6 +8,7 @@ use std::{
time::Duration,
};
use gpui_kit::SharedString;
use unicode_width::UnicodeWidthStr as _;
use super::link::OpenLink;
@@ -17,6 +18,7 @@ use super::protocol::{
RouteDetails, TransferCounters, TransferSummary, parse_json, read_frame, write_json,
};
use crate::host::HostOs;
use crate::i18n::{t, tn};
/// Exit code when the command could not do what was asked at all, as ssh
/// uses it.
@@ -100,25 +102,19 @@ impl Asked {
}
}
/// What an app from before a request was added says about it: it cannot
/// read the request, and serde names what it did not know.
const OLDER_APP: &str =
"正在运行的 ShellRS 不认识这个命令:请把 ShellRS 升级到最新版本,然后重新启动它";
/// Send `request` to the app listening at `endpoint` and print the answer;
/// the exit code.
pub fn run(endpoint: &Path, request: Request, console: &mut Console) -> i32 {
let result = connect(endpoint).and_then(|stream| talk(&stream, request, console));
match result {
Ok(code) => code,
Err(Failure::NotRunning) => console.error(
ErrorCode::NotRunning,
"ShellRS 未运行:请先打开 ShellRS,并在 设置 → 外部 CLI 中打开「启用外部 CLI」",
),
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, message),
Err(Failure::NotRunning) => {
console.error(ErrorCode::NotRunning, &t!("cli.client.not_running"))
}
Err(Failure::Refused(message)) => console.error(ErrorCode::ConnectFailed, &message),
Err(Failure::Broken(error)) => console.error(
ErrorCode::ConnectFailed,
&format!("与 ShellRS 的连接意外中断:{error}"),
&t!("cli.client.broken", error = error),
),
}
}
@@ -172,7 +168,7 @@ where
enum Failure {
NotRunning,
/// Not allowed to talk to the app, or not willing to.
Refused(&'static str),
Refused(SharedString),
Broken(io::Error),
}
@@ -182,19 +178,20 @@ impl From<io::Error> for Failure {
}
}
/// What a sandbox that forbids local connections looks like.
const NO_PERMISSION: &str =
"没有权限连接 ShellRS:Agent 可能运行在沙箱中,需要允许它访问本机的进程间通信";
#[cfg(unix)]
fn connect(socket: &Path) -> Result<std::os::unix::net::UnixStream, Failure> {
std::os::unix::net::UnixStream::connect(socket).map_err(|error| match error.kind() {
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
_ => error.into(),
})
}
/// What a sandbox that forbids local connections looks like.
fn no_permission() -> SharedString {
t!("cli.client.no_permission")
}
/// Open the app's pipe, and make sure it is the app's before saying
/// anything: the name is the machine's, so someone else could hold it.
#[cfg(windows)]
@@ -203,20 +200,18 @@ fn connect(pipe: &Path) -> Result<std::fs::File, Failure> {
let pipe = pipe_windows::open(pipe).map_err(|error| match error.kind() {
io::ErrorKind::NotFound => Failure::NotRunning,
io::ErrorKind::PermissionDenied => Failure::Refused(NO_PERMISSION),
io::ErrorKind::PermissionDenied => Failure::Refused(no_permission()),
_ => error.into(),
})?;
if !pipe_windows::owned_by_current_user(&pipe)? {
return Err(Failure::Refused(
"外部 CLI 的管道不属于当前用户,已拒绝连接",
));
return Err(Failure::Refused(t!("cli.client.foreign_pipe")));
}
Ok(pipe)
}
#[cfg(not(any(unix, windows)))]
fn connect(_: &Path) -> Result<std::fs::File, Failure> {
Err(Failure::Refused("此系统暂不支持外部 CLI"))
Err(Failure::Refused(t!("cli.unsupported")))
}
/// Send the request and print the replies until the last one.
@@ -306,7 +301,11 @@ where
code: ErrorCode::BadRequest,
message,
} if message.contains("unknown variant") => {
return Ok(console.error(ErrorCode::VersionMismatch, OLDER_APP));
// What an app from before the request was added
// says: serde names what it did not know.
return Ok(
console.error(ErrorCode::VersionMismatch, &t!("cli.client.older_app"))
);
}
Reply::Error { code, message } => return Ok(console.error(code, &message)),
}
@@ -353,7 +352,7 @@ fn print_json(value: &impl serde::Serialize, console: &mut Console) -> io::Resul
/// Rows lined up in columns, wide characters counted as two.
fn print_table<const N: usize>(
header: [&str; N],
header: [SharedString; N],
rows: &[[String; N]],
console: &mut Console,
) -> io::Result<()> {
@@ -379,7 +378,7 @@ fn print_table<const N: usize>(
}
/// Labelled lines, the labels lined up.
fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<()> {
fn print_fields(fields: &[(SharedString, String)], console: &mut Console) -> io::Result<()> {
let width = fields
.iter()
.map(|(label, _)| label.width())
@@ -392,12 +391,14 @@ fn print_fields(fields: &[(&str, String)], console: &mut Console) -> io::Result<
Ok(())
}
fn saved_word(saved: Option<bool>) -> &'static str {
match saved {
Some(true) => "(已保存)",
Some(false) => "(未保存)",
None => "",
}
/// `label` saying whether its password is saved, when that is known.
fn with_saved(label: SharedString, saved: Option<bool>) -> String {
let text = match saved {
Some(true) => t!("cli.show.saved", label = label),
Some(false) => t!("cli.show.not_saved", label = label),
None => label,
};
text.to_string()
}
fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Result<()> {
@@ -408,80 +409,86 @@ fn print_host(host: &HostDetails, asked: Asked, console: &mut Console) -> io::Re
Asked::Create => {
return writeln!(
console.stdout,
"已创建主机「{}」,ID {}",
host.name, host.id
"{}",
t!("cli.host.created", name = host.name, id = host.id)
);
}
Asked::Update => {
return writeln!(
console.stdout,
"已保存主机「{}」(ID {})",
host.name, host.id
"{}",
t!("cli.host.saved", name = host.name, id = host.id)
);
}
_ => {}
}
let auth = match host.auth {
AuthChoice::Password => format!("密码{}", saved_word(host.password_saved)),
AuthChoice::Credential => {
format!("凭据 {}", host.credential.as_deref().unwrap_or_default())
}
AuthChoice::NoPassword => "无密码".to_string(),
AuthChoice::Password => with_saved(t!("cli.auth.password"), host.password_saved),
AuthChoice::Credential => t!(
"cli.auth.credential",
id = host.credential.as_deref().unwrap_or_default()
)
.to_string(),
AuthChoice::NoPassword => t!("cli.auth.no_password").to_string(),
};
let route = match &host.route {
RouteDetails::Direct => "直接连接".to_string(),
RouteDetails::Jump { hosts } => format!(
"SSH 跳板 {}",
hosts
RouteDetails::Direct => t!("cli.route.direct").to_string(),
RouteDetails::Jump { hosts } => {
let deleted = t!("cli.route.deleted_host");
let hosts = hosts
.iter()
.map(|hop| hop.as_deref().unwrap_or("(已删除的主机)"))
.map(|hop| hop.as_deref().unwrap_or(deleted.as_str()))
.collect::<Vec<_>>()
.join(" → ")
),
.join(" → ");
t!("cli.route.jump", hosts = hosts).to_string()
}
RouteDetails::Proxy {
kind,
host,
port,
user,
password_saved,
} => format!(
"{} {}{host}:{port}{}",
match kind {
ProxyChoice::Http => "HTTP 代理",
ProxyChoice::Socks5 => "SOCKS5 代理",
},
user.as_deref()
} => {
let user = user
.as_deref()
.map(|user| format!("{user}@"))
.unwrap_or_default(),
password_saved
.map(|saved| if saved {
"(密码已保存)"
} else {
"(密码未保存)"
})
.unwrap_or_default()
),
.unwrap_or_default();
let address = format!("{user}{host}:{port}");
let proxy = match kind {
ProxyChoice::Http => t!("cli.route.http", address = address),
ProxyChoice::Socks5 => t!("cli.route.socks5", address = address),
};
let proxy = match password_saved {
Some(true) => t!("cli.route.password_saved", proxy = proxy),
Some(false) => t!("cli.route.password_not_saved", proxy = proxy),
None => proxy,
};
proxy.to_string()
}
};
let mut fields = vec![
("ID", host.id.clone()),
("名称", host.name.clone()),
(t!("cli.field.id"), host.id.clone()),
(t!("cli.field.name"), host.name.clone()),
(
"分组",
t!("cli.field.group"),
if host.temporary {
"(未保存)".to_string()
t!("cli.group.not_saved").to_string()
} else {
host.group.clone().unwrap_or_default()
},
),
("地址", format!("{}@{}:{}", host.user, host.host, host.port)),
("认证", auth),
("连接方式", route),
(
t!("cli.field.address"),
format!("{}@{}:{}", host.user, host.host, host.port),
),
(t!("cli.field.auth"), auth),
(t!("cli.field.route"), route),
];
if let Some(os) = host.os.as_deref().and_then(HostOs::from_stored) {
fields.push(("系统", os.label().to_string()));
fields.push((t!("cli.field.os"), os.label().to_string()));
}
if !host.notes.is_empty() {
fields.push(("备注", host.notes.clone()));
fields.push((t!("cli.field.notes"), host.notes.clone()));
}
print_fields(&fields, console)
}
@@ -490,24 +497,31 @@ fn print_host_deleted(deleted: &HostDeleted, console: &mut Console) -> io::Resul
if console.json {
return print_json(deleted, console);
}
let mut line = format!("已删除主机「{}」", deleted.host.name);
if deleted.forwards > 0 {
line.push_str(&format!(",连同它的 {} 条端口转发规则", deleted.forwards));
}
let name = &deleted.host.name;
let mut line = if deleted.forwards > 0 {
tn!(
"cli.host.deleted_with_forwards",
deleted.forwards,
name = name
)
} else {
t!("cli.host.deleted", name = name)
};
if deleted.jump_users > 0 {
line.push_str(&format!(
";{} 台主机经它跳转,现在那一跳是「已删除的主机」",
deleted.jump_users
));
line = tn!(
"cli.host.deleted_jump_users",
deleted.jump_users,
deleted = line
);
}
writeln!(console.stdout, "{line}")
}
fn kind_label(kind: CredentialKindChoice) -> &'static str {
fn kind_label(kind: CredentialKindChoice) -> SharedString {
match kind {
CredentialKindChoice::Password => "密码",
CredentialKindChoice::Key => "密钥",
CredentialKindChoice::Agent => "SSH Agent",
CredentialKindChoice::Password => t!("cli.kind.password"),
CredentialKindChoice::Key => t!("cli.kind.key"),
CredentialKindChoice::Agent => t!("cli.kind.agent"),
}
}
@@ -516,7 +530,7 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -
return print_json(&credentials, console);
}
if credentials.is_empty() {
return writeln!(console.stderr, "没有匹配的凭据");
return writeln!(console.stderr, "{}", t!("cli.credentials.none"));
}
let rows: Vec<[String; 5]> = credentials
.iter()
@@ -530,7 +544,17 @@ fn print_credentials(credentials: &[CredentialDetails], console: &mut Console) -
]
})
.collect();
print_table(["ID", "名称", "类型", "用户名", "主机数"], &rows, console)
print_table(
[
t!("cli.column.id"),
t!("cli.column.name"),
t!("cli.column.kind"),
t!("cli.column.user"),
t!("cli.column.hosts"),
],
&rows,
console,
)
}
fn print_credential(
@@ -545,43 +569,52 @@ fn print_credential(
Asked::Create => {
return writeln!(
console.stdout,
"已创建凭据「{}」,ID {}",
credential.name, credential.id
"{}",
t!(
"cli.credential.created",
name = credential.name,
id = credential.id
)
);
}
Asked::Update => {
return writeln!(
console.stdout,
"已保存凭据「{}」(ID {})",
credential.name, credential.id
"{}",
t!(
"cli.credential.saved",
name = credential.name,
id = credential.id
)
);
}
_ => {}
}
let mut fields = vec![
("ID", credential.id.clone()),
("名称", credential.name.clone()),
(t!("cli.field.id"), credential.id.clone()),
(t!("cli.field.name"), credential.name.clone()),
(
"类型",
format!(
"{}{}",
kind_label(credential.kind),
saved_word(credential.password_saved)
),
t!("cli.field.kind"),
with_saved(kind_label(credential.kind), credential.password_saved),
),
("用户名", credential.user.clone()),
(t!("cli.field.user"), credential.user.clone()),
];
if credential.kind == CredentialKindChoice::Key {
let key = match (&credential.key_path, credential.kept) {
(Some(path), _) => path.clone(),
(None, _) => "由 ShellRS 保存".to_string(),
(None, _) => t!("cli.credential.kept_key").to_string(),
};
fields.push(("私钥", key));
fields.push((t!("cli.field.private_key"), key));
if let Some(saved) = credential.passphrase_saved {
fields.push(("口令", if saved { "已保存" } else { "未保存" }.to_string()));
let saved = if saved {
t!("cli.credential.passphrase_saved")
} else {
t!("cli.credential.passphrase_not_saved")
};
fields.push((t!("cli.field.passphrase"), saved.to_string()));
}
}
fields.push(("主机", credential.hosts.join(", ")));
fields.push((t!("cli.field.hosts"), credential.hosts.join(", ")));
print_fields(&fields, console)
}
@@ -589,18 +622,20 @@ fn print_credential_deleted(deleted: &CredentialDeleted, console: &mut Console)
if console.json {
return print_json(deleted, console);
}
let mut line = format!("已删除凭据「{}」", deleted.credential.name);
let mut line = t!("cli.credential.deleted", name = deleted.credential.name);
if !deleted.released.is_empty() {
let names: Vec<&str> = deleted
.released
.iter()
.map(|host| host.name.as_str())
.collect();
line.push_str(&format!(
";{} 台主机改为自己登录:{}",
let separator = t!("common.list.separator");
line = tn!(
"cli.credential.deleted_released",
names.len(),
names.join("、")
));
deleted = line,
hosts = names.join(separator.as_str())
);
}
writeln!(console.stdout, "{line}")
}
@@ -610,7 +645,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
return print_json(&hosts, console);
}
if hosts.is_empty() {
return writeln!(console.stderr, "没有匹配的主机");
return writeln!(console.stderr, "{}", t!("cli.hosts.none"));
}
let rows: Vec<[String; 5]> = hosts
.iter()
@@ -621,7 +656,7 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
// 临时连接 and 外部连接 alike: an agent needs no telling
// them apart, only that the host is not saved.
if host.temporary {
"(未保存)".to_string()
t!("cli.group.not_saved").to_string()
} else {
host.group.clone().unwrap_or_default()
},
@@ -634,7 +669,17 @@ fn print_hosts(hosts: &[HostInfo], console: &mut Console) -> io::Result<()> {
]
})
.collect();
print_table(["ID", "名称", "分组", "地址", "系统"], &rows, console)
print_table(
[
t!("cli.column.id"),
t!("cli.column.name"),
t!("cli.column.group"),
t!("cli.column.address"),
t!("cli.column.os"),
],
&rows,
console,
)
}
/// A sync's skipped items are the ones that had not changed.
@@ -642,20 +687,35 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -
if console.json {
print_json(summary, console)?;
} else {
let mut line = format!(
"已传输 {} 个文件,共 {}",
let mut line = tn!(
"cli.summary.transferred",
summary.files,
format_bytes(summary.bytes)
size = format_bytes(summary.bytes)
);
if summary.skipped > 0 {
let word = if sync { "未变" } else { "跳过" };
line.push_str(&format!(",{word} {} 个", summary.skipped));
line = if sync {
t!(
"cli.summary.unchanged",
summary = line,
count = summary.skipped
)
} else {
t!(
"cli.summary.skipped",
summary = line,
count = summary.skipped
)
};
}
if summary.deleted > 0 {
line.push_str(&format!(",删除 {} 个", summary.deleted));
line = t!(
"cli.summary.deleted",
summary = line,
count = summary.deleted
);
}
if summary.failed > 0 {
line.push_str(&format!(",失败 {} 个", summary.failed));
line = t!("cli.summary.failed", summary = line, count = summary.failed);
}
writeln!(console.stdout, "{line}")?;
for failure in &summary.failures {
@@ -666,13 +726,14 @@ fn print_summary(summary: &TransferSummary, sync: bool, console: &mut Console) -
}
fn progress_line(counters: TransferCounters) -> String {
format!(
"{}/{} 个文件,{} / {}",
counters.files,
counters.total_files,
format_bytes(counters.bytes),
format_bytes(counters.total_bytes)
t!(
"cli.progress",
files = counters.files,
total_files = counters.total_files,
bytes = format_bytes(counters.bytes),
total_bytes = format_bytes(counters.total_bytes)
)
.to_string()
}
fn format_bytes(bytes: u64) -> String {
@@ -688,3 +749,143 @@ fn format_bytes(bytes: u64) -> String {
}
format!("{value:.1} {}", UNITS[unit])
}
#[cfg(test)]
mod tests {
use super::*;
/// What `print` writes to stdout, as on a terminal.
fn printed(print: impl FnOnce(&mut Console) -> io::Result<()>) -> String {
let (mut stdout, mut stderr) = (Vec::new(), Vec::new());
print(&mut Console {
stdout: &mut stdout,
stderr: &mut stderr,
json: false,
exec_json: false,
stderr_is_terminal: false,
})
.unwrap();
String::from_utf8(stdout).unwrap()
}
fn host(name: &str) -> HostDetails {
HostDetails {
id: "Jwg5rHvXCxw89paM".into(),
name: name.into(),
group: None,
host: "10.0.0.9".into(),
port: 22,
user: "root".into(),
auth: AuthChoice::Password,
credential: None,
password_saved: None,
route: RouteDetails::Direct,
notes: String::new(),
os: None,
temporary: false,
}
}
fn deleted_host(forwards: u64, jump_users: u64) -> String {
let deleted = HostDeleted {
host: host("web"),
forwards,
jump_users,
};
printed(|console| print_host_deleted(&deleted, console))
}
fn deleted_credential(released: &[&str]) -> String {
let info = |name: &&str| HostInfo {
id: String::new(),
name: name.to_string(),
group: None,
user: "root".into(),
host: "10.0.0.9".into(),
port: 22,
os: None,
temporary: false,
};
let deleted = CredentialDeleted {
credential: CredentialDetails {
id: String::new(),
name: "deploy".into(),
kind: CredentialKindChoice::Password,
user: "deploy".into(),
key_path: None,
kept: false,
hosts: Vec::new(),
password_saved: None,
passphrase_saved: None,
},
released: released.iter().map(info).collect(),
};
printed(|console| print_credential_deleted(&deleted, console))
}
fn summary(files: u64, skipped: u64, sync: bool) -> String {
let summary = TransferSummary {
files,
bytes: 2048,
skipped,
failed: 1,
deleted: 0,
failures: Vec::new(),
};
printed(|console| print_summary(&summary, sync, console).map(drop))
}
#[test]
fn what_was_done_reads_as_one_line_in_either_language() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("zh-CN");
assert_eq!(deleted_host(0, 0), "已删除主机「web」\n");
assert_eq!(
deleted_host(2, 3),
"已删除主机「web」,连同它的 2 条端口转发规则;\
3 台主机经它跳转,现在那一跳是「已删除的主机」\n"
);
assert_eq!(
deleted_credential(&["web", "db"]),
"已删除凭据「deploy」;2 台主机改为自己登录:web、db\n"
);
assert_eq!(
summary(2, 40, true),
"已传输 2 个文件,共 2.0 KB,未变 40 个,失败 1 个\n"
);
crate::i18n::set_locale("en");
assert_eq!(deleted_host(0, 0), "Deleted host \"web\"\n");
assert_eq!(
deleted_host(1, 1),
"Deleted host \"web\" and its port forwarding rule; \
1 host jumped through it and now has a deleted jump host\n"
);
assert_eq!(
deleted_credential(&["web", "db"]),
"Deleted credential \"deploy\"; 2 hosts now log in on their own: web, db\n"
);
assert_eq!(
summary(1, 4, false),
"Transferred 1 file (2.0 KB), 4 skipped, 1 failed\n"
);
}
#[test]
fn an_english_show_lines_up_its_labels() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("en");
let mut shown = host("web");
shown.password_saved = Some(true);
let shown = printed(|console| print_host(&shown, Asked::Show, console));
assert!(
shown.contains("\nAuthentication Password (saved)\n"),
"{shown}"
);
assert!(shown.contains("\nConnection Direct\n"), "{shown}");
assert!(
shown.starts_with("ID Jwg5rHvXCxw89paM\n"),
"{shown}"
);
}
}
+18 -11
View File
@@ -7,6 +7,10 @@
use std::path::Path;
use std::{fs, io, path::PathBuf};
use gpui_kit::SharedString;
use crate::i18n::t;
/// The skill that teaches an agent to use `shellrs`.
pub const SKILL: &str = include_str!("SKILL.md");
@@ -36,19 +40,19 @@ impl AgentKind {
AgentKind::WorkBuddy,
];
pub fn label(self) -> &'static str {
pub fn label(self) -> SharedString {
match self {
AgentKind::Generic => "通用 Agent",
AgentKind::Codex => "Codex",
AgentKind::ClaudeCode => "Claude Code",
AgentKind::OpenCode => "OpenCode",
AgentKind::WorkBuddy => "WorkBuddy",
AgentKind::Generic => t!("cli.agent.generic"),
AgentKind::Codex => "Codex".into(),
AgentKind::ClaudeCode => "Claude Code".into(),
AgentKind::OpenCode => "OpenCode".into(),
AgentKind::WorkBuddy => "WorkBuddy".into(),
}
}
pub fn description(self) -> Option<&'static str> {
pub fn description(self) -> Option<SharedString> {
match self {
AgentKind::Generic => Some("安装到 Agent Skills 标准目录,供支持该目录的 Agent 使用。"),
AgentKind::Generic => Some(t!("cli.agent.generic_description")),
_ => None,
}
}
@@ -277,7 +281,7 @@ fn link_binary(paths: &IntegrationPaths) -> io::Result<()> {
#[cfg(not(unix))]
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持安装 shellrs 命令",
t!("cli.install.unsupported").to_string(),
))
}
@@ -300,7 +304,7 @@ fn remove_link(paths: &IntegrationPaths) -> io::Result<()> {
fn occupied(link: &Path) -> io::Error {
io::Error::new(
io::ErrorKind::AlreadyExists,
format!("{} 已被其他程序占用,ShellRS 不会改动它", link.display()),
t!("cli.install.occupied", path = link.display()).to_string(),
)
}
@@ -323,7 +327,10 @@ fn run_as_administrator(command: &str) -> io::Result<()> {
let message = String::from_utf8_lossy(&output.stderr);
// AppleScript's "User canceled." is error -128.
if message.contains("-128") {
return Err(io::Error::new(io::ErrorKind::Interrupted, "已取消"));
return Err(io::Error::new(
io::ErrorKind::Interrupted,
t!("cli.install.cancelled").to_string(),
));
}
Err(io::Error::other(message.trim().to_string()))
}
+2 -1
View File
@@ -12,6 +12,7 @@ use std::{
};
use super::install::{BinaryStatus, IntegrationPaths, UserPath};
use crate::i18n::t;
pub(super) fn binary_status(paths: &IntegrationPaths) -> BinaryStatus {
let source = fs::metadata(&paths.exe).ok();
@@ -56,7 +57,7 @@ pub(super) fn install_binary(paths: &IntegrationPaths) -> io::Result<()> {
if !paths.exe.exists() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("找不到 {}", paths.exe.display()),
t!("cli.install.missing", path = paths.exe.display()).to_string(),
));
}
let folder = folder(paths);
+1 -1
View File
@@ -46,7 +46,7 @@ fn without_password(url: &str) -> String {
}
parsed.to_string()
}
Err(_) => "<无法解析的链接>".into(),
Err(_) => "<unreadable link>".into(),
}
}
+39 -42
View File
@@ -9,6 +9,7 @@ use crate::host::{
AuthKind, Credential, CredentialDraft, CredentialId, CredentialKind, GroupId, Host, HostDraft,
HostId, HostStore, PastedKey, ProxyKind, ProxySettings, Route,
};
use crate::i18n::t;
use crate::secrets::SecretRef;
use super::protocol::{
@@ -202,9 +203,10 @@ pub fn with_saved_credential_secrets(
/// The credential search: its name, user, kind or ID.
pub fn credential_matches(details: &CredentialDetails, query: &str) -> bool {
let needle = query.trim().to_lowercase();
// Both languages' words, whichever the agent or its user types.
let kind = match details.kind {
CredentialKindChoice::Password => "password 密码",
CredentialKindChoice::Key => "key 密钥",
CredentialKindChoice::Password => "password 密码", // i18n: keep
CredentialKindChoice::Key => "key 密钥", // i18n: keep
CredentialKindChoice::Agent => "agent ssh agent",
};
needle.is_empty()
@@ -218,10 +220,17 @@ fn bad(message: impl Into<String>) -> CliError {
CliError::new(ErrorCode::BadRequest, message)
}
fn host_not_found(id: &str) -> CliError {
pub(super) fn host_not_found(id: &str) -> CliError {
CliError::new(
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
t!("cli.error.host_not_found", id = id),
)
}
pub(super) fn credential_not_found(id: &str) -> CliError {
CliError::new(
ErrorCode::CredentialNotFound,
t!("cli.error.credential_not_found", id = id),
)
}
@@ -239,10 +248,7 @@ pub fn find_host<'a>(store: &'a HostStore, id: &str) -> Result<&'a Host, CliErro
pub fn find_saved_host(store: &HostStore, id: &str) -> Result<HostId, CliError> {
let host = find_host(store, id)?;
if store.is_temporary(host.id) {
return Err(bad(format!(
"「{}」是没有保存的连接(临时连接或外部连接),不能修改或删除:关闭它的标签即可",
host.name
)));
return Err(bad(t!("cli.host.temporary", name = host.name)));
}
Ok(host.id)
}
@@ -252,12 +258,7 @@ pub fn find_credential<'a>(store: &'a HostStore, id: &str) -> Result<&'a Credent
.credentials()
.iter()
.find(|credential| credential.keychain_id.as_str() == id.trim())
.ok_or_else(|| {
CliError::new(
ErrorCode::CredentialNotFound,
format!("没有 ID 为 {id} 的凭据:请用 shellrs credentials list 查看"),
)
})
.ok_or_else(|| credential_not_found(id))
}
/// Where a host goes in the tree.
@@ -359,9 +360,7 @@ pub fn plan_host(
};
let credential = match (fields.auth, named) {
(Some(AuthChoice::Password | AuthChoice::NoPassword), Some(_)) => {
return Err(bad(
"auth 为 password 或 no_password 时不能同时指定 credential",
));
return Err(bad(t!("cli.host.credential_with_own_auth")));
}
(Some(AuthChoice::Password | AuthChoice::NoPassword), None) => None,
(_, Some(named)) => Some(named),
@@ -369,15 +368,12 @@ pub fn plan_host(
(_, None) => draft.credential.and_then(|id| store.credential(id)),
};
match (fields.auth, credential) {
(Some(AuthChoice::Credential), None) => return Err(bad("请选择凭据")),
(Some(AuthChoice::Credential), None) => return Err(bad(t!("cli.host.no_credential"))),
(_, Some(credential)) => {
if let Some(user) = &fields.user
&& user.trim() != credential.user.as_ref()
{
return Err(bad(format!(
"使用凭据的主机以凭据的用户名 {} 登录:去掉 user,或改凭据的用户名",
credential.user
)));
return Err(bad(t!("cli.host.credential_user", user = credential.user)));
}
draft.credential = Some(credential.id);
draft.user = credential.user.clone();
@@ -401,7 +397,7 @@ pub fn plan_host(
}
let own_password = draft.credential.is_none() && draft.auth == AuthKind::Password;
if !own_password && matches!(fields.password, Some(Some(_))) {
return Err(bad("只有 auth 为 password 的主机才保存自己的密码"));
return Err(bad(t!("cli.host.password_without_auth")));
}
// The proxy password field, when the route was given at all.
@@ -419,7 +415,7 @@ pub fn plan_host(
} => {
let user = user.unwrap_or_default();
if user.trim().is_empty() && matches!(password, Some(Some(_))) {
return Err(bad("填写代理密码时请同时填写用户名"));
return Err(bad(t!("cli.host.proxy_password_without_user")));
}
proxy_field = Some(password);
let kind = match kind {
@@ -475,13 +471,13 @@ fn jump_hosts(
};
let hop = find_host(store, id)?;
if store.is_temporary(hop.id) {
return Err(bad(format!("跳板只能是保存的主机:{id} 是没有保存的连接")));
return Err(bad(t!("cli.host.jump_temporary", id = id)));
}
if Some(hop.id) == own {
return Err(bad("跳板主机不能是这台主机自己"));
return Err(bad(t!("cli.host.jump_self")));
}
if hops.contains(&Some(hop.id)) {
return Err(bad(format!("跳板主机 {id} 重复了")));
return Err(bad(t!("cli.host.jump_repeated", id = id)));
}
hops.push(Some(hop.id));
}
@@ -498,14 +494,11 @@ fn resolve_group(store: &HostStore, path: &str) -> Result<GroupPlan, CliError> {
}
let names: Vec<&str> = path.split('/').map(str::trim).collect();
if names.iter().any(|name| name.is_empty()) {
return Err(bad(format!("分组路径「{path}」里有空的一级")));
return Err(bad(t!("cli.group.empty_level", path = path)));
}
let wanted = names.join("/");
let ambiguous = |count: usize, path: &str| {
bad(format!(
"有 {count} 个分组的路径都是「{path}」:请先在 ShellRS 里给它们改名"
))
};
let ambiguous =
|count: usize, path: &str| bad(t!("cli.group.ambiguous", count = count, path = path));
let whole: Vec<GroupId> = store
.groups()
.iter()
@@ -587,22 +580,25 @@ pub fn plan_credential(
draft.user = user.into();
}
if gives_key && kind != CredentialKind::Key {
return Err(bad("只有密钥凭据(kind 为 key)才有私钥"));
return Err(bad(t!("cli.credential.key_without_kind")));
}
if kind != CredentialKind::Password && matches!(fields.password, Some(Some(_))) {
return Err(bad("只有密码凭据(kind 为 password)才保存密码"));
return Err(bad(t!("cli.credential.password_without_kind")));
}
if kind != CredentialKind::Key && matches!(fields.passphrase, Some(Some(_))) {
return Err(bad("只有密钥凭据(kind 为 key)才有口令"));
return Err(bad(t!("cli.credential.passphrase_without_kind")));
}
let private_key = match (fields.key_path, fields.private_key) {
(Some(_), Some(_)) => return Err(bad("key_path 和 private_key 只能给一个")),
(Some(_), Some(_)) => return Err(bad(t!("cli.credential.two_keys"))),
(Some(path), None) => {
if !path.is_absolute() {
return Err(bad(format!("私钥文件必须是绝对路径:{}", path.display())));
return Err(bad(t!(
"cli.credential.key_not_absolute",
path = path.display()
)));
}
if !Path::new(&path).is_file() {
return Err(bad(format!("私钥文件不存在:{}", path.display())));
return Err(bad(t!("cli.credential.key_missing", path = path.display())));
}
draft = draft.with_key_path(path.display().to_string());
None
@@ -612,11 +608,12 @@ pub fn plan_credential(
if store.key_dir().is_none() {
return Err(CliError::new(
ErrorCode::SaveFailed,
"没有可以保存私钥的目录",
t!("cli.credential.no_key_dir"),
));
}
// Stands in until the key file is written, so the draft passes.
draft = draft.with_key_path("(新私钥)");
// Stands in until the key file is written, so the draft passes;
// never saved or shown.
draft = draft.with_key_path("(pasted key)");
Some(pasted)
}
(None, None) => None,
+44 -17
View File
@@ -26,6 +26,8 @@ use std::{
use clap::{Parser, Subcommand};
use crate::i18n::t;
pub use backend::SshCliBackend;
pub use client::{Console, FAILURE_EXIT, PARTIAL_EXIT, activate_running_app};
pub use install::{
@@ -48,12 +50,17 @@ pub use protocol::{
};
pub use server::{ChangeReply, CliBackend, CliServer, CliTarget, CliUse};
const AFTER_HELP: &str = "\
Hosts are named by the 16-character ID that `shellrs hosts list` prints (the one ShellRS copies with 复制 ID).
ShellRS must be running, with 设置 → 外部 CLI → 启用外部 CLI turned on.
Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;
255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).";
// `--help` is English whatever the interface language, so it names the
// settings it points to in both.
const AFTER_HELP: &str = concat!(
"Hosts are named by the 16-character ID that `shellrs hosts list` prints, ",
"the one ShellRS copies with Copy ID (复制 ID).\n", // i18n: keep
"ShellRS must be running, with Settings → External CLI → Enable external CLI ",
"(设置 → 外部 CLI → 启用外部 CLI) turned on.\n", // i18n: keep
"\n",
"Exit codes: exec exits with the remote command's code; 1 means a transfer finished with failures;\n",
"255 means shellrs could not do what was asked (the reason is printed as `shellrs: [code] message`).",
);
const HOST_FIELDS: &str = "\
Reads one JSON object from stdin. Every field may be left out; `update` changes only the fields given.
@@ -61,7 +68,7 @@ Reads one JSON object from stdin. Every field may be left out; `update` changes
host The address (IP or host name). Required to create. Also read as `address`.
port Default 22.
user Default root. A host using a credential logs in as the credential's user.
group A group path such as \"生产/数据库\", made when it is not there; null for the top level.
group A group path such as \"Production/Databases\", made when it is not there; null for the top level.
auth \"password\", \"credential\" or \"no_password\".
credential A credential ID from `shellrs credentials list`, for auth \"credential\" (implied).
password The host's own password, saved to the keychain and never printed; null deletes it.
@@ -332,7 +339,7 @@ fn request(command: Command, console: &mut Console) -> Result<Request, i32> {
};
let command = normalize_command(&command);
if command.trim().is_empty() {
return Err(console.error(ErrorCode::BadRequest, "命令不能为空"));
return Err(console.error(ErrorCode::BadRequest, &t!("cli.exec.empty")));
}
Request::Exec { host, command }
}
@@ -450,7 +457,7 @@ fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value,
if io::stdin().is_terminal() {
return Err(console.error(
ErrorCode::BadRequest,
&format!("请从标准输入传入 JSON,例如 {example}"),
&t!("cli.json.from_terminal", example = example),
));
}
let text = read_stdin(console)?;
@@ -458,23 +465,31 @@ fn read_json(console: &mut Console, example: &str) -> Result<serde_json::Value,
serde_json::from_str(text).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&format!("标准输入不是 JSON:{error}"),
&t!("cli.json.not_json", error = error),
)
})
}
fn host_fields(console: &mut Console, example: &str) -> Result<HostFields, i32> {
let value = read_json(console, example)?;
manage::host_fields(value)
.map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}")))
manage::host_fields(value).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.invalid", error = error),
)
})
}
/// A key file named relative to here, or to the home directory, is sent
/// as the absolute path the app can find.
fn credential_fields(console: &mut Console, example: &str) -> Result<CredentialFields, i32> {
let value = read_json(console, example)?;
let mut fields = manage::credential_fields(value)
.map_err(|error| console.error(ErrorCode::BadRequest, &format!("JSON 有误:{error}")))?;
let mut fields = manage::credential_fields(value).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&t!("cli.json.invalid", error = error),
)
})?;
if let Some(path) = fields.key_path.take() {
let path = match path.strip_prefix("~") {
Ok(rest) => dirs::home_dir().map(|home| home.join(rest)).unwrap_or(path),
@@ -490,7 +505,10 @@ fn read_stdin(console: &mut Console) -> Result<String, i32> {
std::io::stdin()
.read_to_string(&mut text)
.map_err(|error| {
console.error(ErrorCode::BadRequest, &format!("无法读取标准输入:{error}"))
console.error(
ErrorCode::BadRequest,
&t!("cli.stdin.unreadable", error = error),
)
})?;
Ok(text)
}
@@ -506,7 +524,12 @@ struct ExecJson {
fn exec_request(text: &str) -> Result<(String, String), String> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let request: ExecJson = serde_json::from_str(text).map_err(|error| {
format!("标准输入不是 {{\"host\": ..., \"command\": ...}} 形式的 JSON:{error}")
t!(
"cli.exec.bad_json",
form = r#"{"host": ..., "command": ...}"#,
error = error
)
.to_string()
})?;
Ok((request.host, request.command))
}
@@ -526,7 +549,11 @@ fn absolute(path: PathBuf, console: &mut Console) -> Result<PathBuf, i32> {
std::path::absolute(&path).map_err(|error| {
console.error(
ErrorCode::BadRequest,
&format!("无法解析路径 {}:{error}", path.display()),
&t!(
"cli.path.unresolvable",
path = path.display(),
error = error
),
)
})
}
+15 -24
View File
@@ -21,8 +21,8 @@ use tokio::sync::watch;
use super::link::OpenLink;
use super::manage::{
CliChange, CredentialSecrets, HostSecrets, credential_details, credential_matches,
credential_secrets, host_details, host_info, host_secrets, with_saved_credential_secrets,
with_saved_passwords,
credential_not_found, credential_secrets, host_details, host_info, host_not_found,
host_secrets, with_saved_credential_secrets, with_saved_passwords,
};
use super::protocol::{
CliError, CredentialDetails, Envelope, ErrorCode, FrameKind, HostDetails, HostInfo,
@@ -30,6 +30,7 @@ use super::protocol::{
read_frame, write_frame, write_json,
};
use crate::host::{Host, HostLogin, HostStore, matches_query};
use crate::i18n::t;
use crate::secrets::SecretRef;
use crate::ssh::ExecStream;
@@ -538,12 +539,15 @@ struct Listener;
fn listen(_: PathBuf, _: Arc<Shared>) -> io::Result<Listener> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"此系统暂不支持外部 CLI",
t!("cli.unsupported").to_string(),
))
}
fn another_app() -> io::Error {
io::Error::new(io::ErrorKind::AddrInUse, "另一个 ShellRS 已在提供外部 CLI")
io::Error::new(
io::ErrorKind::AddrInUse,
t!("cli.server.another_app").to_string(),
)
}
/// Read one request and answer it.
@@ -593,15 +597,14 @@ fn respond(
if version.version != PROTOCOL_VERSION {
return Err(CliError::new(
ErrorCode::VersionMismatch,
"shellrs 命令与正在运行的 ShellRS 版本不同:\
请重新启动 ShellRS,或在 设置 → 外部 CLI 中更新 CLI",
t!("cli.server.version_mismatch"),
));
}
let envelope = envelope.map_err(bad_request)?;
if !shared.enabled.load(Ordering::Acquire) {
return Err(CliError::new(
ErrorCode::NotEnabled,
"ShellRS 未启用外部 CLI:请在 ShellRS 的 设置 → 外部 CLI 中打开「启用外部 CLI」",
t!("cli.server.not_enabled"),
));
}
if let Some(usage) = CliUse::of(&envelope.request) {
@@ -718,14 +721,7 @@ fn respond(
.iter()
.find(|known| known.details.id == credential.trim())
.cloned()
.ok_or_else(|| {
CliError::new(
ErrorCode::CredentialNotFound,
format!(
"没有 ID 为 {credential} 的凭据:请用 shellrs credentials list 查看"
),
)
})?;
.ok_or_else(|| credential_not_found(&credential))?;
let saved = |secret: &SecretRef| shared.backend.is_saved(secret);
Ok(Reply::Credential(with_saved_credential_secrets(
found.details,
@@ -758,7 +754,7 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
let gone = || {
CliError::new(
ErrorCode::ConnectFailed,
"ShellRS 在处理改动时关闭了:请重新打开 ShellRS,用 shellrs hosts list 查看改动是否已完成",
t!("cli.server.closed_during_change"),
)
};
let (sender, outcome) = mpsc::channel();
@@ -786,7 +782,7 @@ fn change(shared: &Shared, change: CliChange) -> Result<Reply, CliError> {
changes.remove(index);
return Err(CliError::new(
ErrorCode::ConnectFailed,
"ShellRS 没有及时处理这项改动,改动没有做:请稍后再试",
t!("cli.server.change_timed_out"),
));
}
}
@@ -802,17 +798,12 @@ fn find(shared: &Shared, id: &str) -> Result<CliTarget, CliError> {
.iter()
.find(|target| target.info.id == id.trim())
.cloned()
.ok_or_else(|| {
CliError::new(
ErrorCode::HostNotFound,
format!("没有 ID 为 {id} 的主机:请用 shellrs hosts list 查看"),
)
})
.ok_or_else(|| host_not_found(id))
}
fn not_absolute(path: &Path) -> CliError {
CliError::new(
ErrorCode::BadRequest,
format!("本地路径必须是绝对路径:{}", path.display()),
t!("cli.server.not_absolute", path = path.display()),
)
}
+15 -12
View File
@@ -3,6 +3,8 @@
use std::fmt;
use crate::i18n::t;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum UpdateError {
/// The server could not be reached, or the transfer broke off.
@@ -30,18 +32,19 @@ pub enum UpdateError {
impl fmt::Display for UpdateError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Network(reason) => write!(f, "无法连接更新服务器:{reason}"),
Self::Http(status) => write!(f, "更新服务器返回 HTTP {status}"),
Self::Stalled => f.write_str("下载长时间没有进展,已中断"),
Self::BadSignature => f.write_str("更新清单的签名无效"),
Self::BadManifest(reason) => write!(f, "无法读取更新清单:{reason}"),
Self::Corrupt => f.write_str("下载的文件与更新清单不符"),
Self::NoSpace => f.write_str("磁盘空间不足"),
Self::Disk(reason) => write!(f, "无法写入更新文件:{reason}"),
Self::Install(reason) => write!(f, "无法安装新版本:{reason}"),
Self::Cancelled => f.write_str("已取消"),
}
let text = match self {
Self::Network(reason) => t!("update.error.network", reason = reason),
Self::Http(status) => t!("update.error.http", status = status),
Self::Stalled => t!("update.error.stalled"),
Self::BadSignature => t!("update.error.bad_signature"),
Self::BadManifest(reason) => t!("update.error.bad_manifest", reason = reason),
Self::Corrupt => t!("update.error.corrupt"),
Self::NoSpace => t!("update.error.no_space"),
Self::Disk(reason) => t!("update.error.disk", reason = reason),
Self::Install(reason) => t!("update.error.install", reason = reason),
Self::Cancelled => t!("update.error.cancelled"),
};
f.write_str(&text)
}
}
+10 -5
View File
@@ -17,6 +17,7 @@ use reqwest::header::{CONTENT_RANGE, RANGE};
use super::build_info::{self, Channel};
use super::error::UpdateError;
use super::platform;
use crate::i18n::t;
/// Where manifests and packages come from.
pub trait UpdateFeed: Send + Sync {
@@ -127,7 +128,9 @@ impl HttpFeed {
if range_start(&response) != Some(have) {
// Not the bytes that were asked for: start over next time.
fs::remove_file(part)?;
return Err(UpdateError::Network("服务器返回的续传位置不对".into()));
return Err(UpdateError::Network(
t!("update.feed.wrong_range").to_string(),
));
}
OpenOptions::new().append(true).open(part)?
}
@@ -159,7 +162,7 @@ impl HttpFeed {
}
file.sync_all().map_err(write_error)?;
if have < size {
return Err(UpdateError::Network("连接在下载完成前断开".into()));
return Err(UpdateError::Network(t!("update.feed.cut_off").to_string()));
}
Ok(())
}
@@ -182,7 +185,9 @@ impl UpdateFeed for HttpFeed {
.read_to_end(&mut body)
.map_err(read_error)?;
if body.len() as u64 > MANIFEST_LIMIT {
return Err(UpdateError::BadManifest("文件过大".into()));
return Err(UpdateError::BadManifest(
t!("update.feed.too_large").to_string(),
));
}
Ok(body)
}
@@ -197,7 +202,7 @@ impl UpdateFeed for HttpFeed {
) -> Result<(), UpdateError> {
let client = self.client()?;
let part = part_path(dest);
let mut last = UpdateError::Network("没有下载地址".into());
let mut last = UpdateError::Network(t!("update.feed.no_urls").to_string());
for url in urls {
match self.download_from(&client, url, size, &part, progress, cancel) {
Ok(()) => {
@@ -257,7 +262,7 @@ fn write_error(error: io::Error) -> UpdateError {
/// URL, the reason is at the end of its chain.
fn describe(error: &reqwest::Error) -> String {
if error.is_timeout() {
return "连接超时".into();
return t!("update.feed.timed_out").to_string();
}
let mut source: &dyn std::error::Error = error;
while let Some(next) = source.source() {
+18 -17
View File
@@ -18,6 +18,7 @@ use semver::Version;
use super::error::UpdateError;
use super::manifest::Release;
use crate::i18n::t;
/// Where this ShellRS runs from, as far as updating goes.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -62,23 +63,19 @@ pub enum Unsupported {
impl Unsupported {
/// Why, for 设置 › 关于 and the update dialog.
pub fn reason(&self) -> String {
match self {
Self::DevelopmentBuild => "开发构建,不检查更新".into(),
Self::Translocated => {
"ShellRS 不在“应用程序”文件夹中,无法自动更新。请把它移到“应用程序”文件夹后重新打开"
.into()
}
Self::DiskImage => {
"ShellRS 正在从磁盘映像运行,无法自动更新。请先把它拖到“应用程序”文件夹".into()
}
let reason = match self {
Self::DevelopmentBuild => t!("update.unsupported.development_build"),
Self::Translocated => t!("update.unsupported.translocated"),
Self::DiskImage => t!("update.unsupported.disk_image"),
Self::ReadOnly(folder) => {
format!("没有权限写入 {},无法自动更新", folder.display())
t!("update.unsupported.read_only", folder = folder.display())
}
Self::NotBundled => "ShellRS 不是从应用程序包运行的,无法自动更新".into(),
Self::NotInstalled => "这份 ShellRS 不是用安装程序安装的,无法自动更新".into(),
Self::NotAppImage => "这份 ShellRS 不是 AppImage,请用安装它的方式更新".into(),
Self::Platform => "ShellRS 没有为这个系统提供更新".into(),
}
Self::NotBundled => t!("update.unsupported.not_bundled"),
Self::NotInstalled => t!("update.unsupported.not_installed"),
Self::NotAppImage => t!("update.unsupported.not_appimage"),
Self::Platform => t!("update.unsupported.platform"),
};
reason.to_string()
}
}
@@ -229,11 +226,15 @@ impl Installer for NoInstaller {
}
fn stage(&self, _: &Path, _: &Release) -> Result<Staged, UpdateError> {
Err(UpdateError::Install("这份 ShellRS 无法自动更新".into()))
Err(UpdateError::Install(
t!("update.install.not_possible").to_string(),
))
}
fn apply(&self, _: &Staged, _: bool) -> Result<Relaunch, UpdateError> {
Err(UpdateError::Install("这份 ShellRS 无法自动更新".into()))
Err(UpdateError::Install(
t!("update.install.not_possible").to_string(),
))
}
fn clean_up(&self) {}
+24 -11
View File
@@ -18,6 +18,7 @@ use super::build_info::APPLE_TEAM_ID;
use super::error::UpdateError;
use super::install::{InstallKind, Installer, Relaunch, Staged, sibling};
use super::manifest::Release;
use crate::i18n::t;
/// The macOS tools staging relies on, behind a seam for the tests.
pub trait MacTools: Send + Sync {
@@ -37,18 +38,28 @@ pub struct SystemTools;
impl SystemTools {
fn run(program: &str, args: &[&OsStr]) -> Result<String, UpdateError> {
let output = Command::new(program)
.args(args)
.output()
.map_err(|error| UpdateError::Install(format!("{program}:{error}")))?;
let output = Command::new(program).args(args).output().map_err(|error| {
UpdateError::Install(
t!(
"update.install.tool_failed",
program = program,
error = error
)
.to_string(),
)
})?;
if output.status.success() {
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
Err(UpdateError::Install(format!(
"{program}:{}",
stderr.trim()
)))
Err(UpdateError::Install(
t!(
"update.install.tool_failed",
program = program,
error = stderr.trim()
)
.to_string(),
))
}
}
}
@@ -94,7 +105,7 @@ impl MacTools for SystemTools {
args.push(bundle.as_os_str());
Self::run("/usr/bin/codesign", &args)
.map(drop)
.map_err(|_| UpdateError::Install("新版本的签名无效或不是 ShellRS 的开发者".into()))
.map_err(|_| UpdateError::Install(t!("update.install.bad_signature").to_string()))
}
}
@@ -151,9 +162,11 @@ impl MacInstaller {
.flatten()
.map(|entry| entry.path())
.find(|path| path.extension().is_some_and(|extension| extension == "app"))
.ok_or_else(|| UpdateError::Install("更新包里没有应用程序".into()))?;
.ok_or_else(|| UpdateError::Install(t!("update.install.no_app").to_string()))?;
if self.tools.bundle_version(&app)? != release.version.to_string() {
return Err(UpdateError::Install("更新包的版本与更新清单不符".into()));
return Err(UpdateError::Install(
t!("update.install.wrong_version").to_string(),
));
}
self.tools.check_signature(&app, self.team.as_deref())?;
Ok(app)
+4 -1
View File
@@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use super::error::UpdateError;
use super::install::{InstallKind, Installer, Launch, Relaunch, Staged};
use super::manifest::Release;
use crate::i18n::t;
/// Waits for ShellRS to exit, runs the setup, then starts ShellRS again if
/// asked. Values come in through the environment, not the script's text,
@@ -85,7 +86,9 @@ impl Installer for SetupInstaller {
fn apply(&self, staged: &Staged, relaunch: bool) -> Result<Relaunch, UpdateError> {
if !staged.path.is_file() {
return Err(UpdateError::Install("找不到下载的安装程序".into()));
return Err(UpdateError::Install(
t!("update.install.setup_missing").to_string(),
));
}
Ok(Relaunch::Hand(waiter(
std::process::id(),
+149 -59
View File
@@ -2,7 +2,10 @@
//! button, which buttons apply, and what a restart interrupts. Pure, so
//! every wording is tested without a window.
use gpui_kit::SharedString;
use super::updater::{Phase, Stage, UpdateSnapshot};
use crate::i18n::{join_list, t, tn};
/// The title bar's update button.
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -52,51 +55,76 @@ impl UpdateSnapshot {
/// The line under 当前版本 on 设置 › 关于.
pub fn status_line(&self) -> (String, Tone) {
let offered = self.offered_version().unwrap_or_default();
match &self.phase {
let version = self.offered_version().unwrap_or_default();
let version = version.as_str();
let (text, tone) = match &self.phase {
Phase::Off => (
format!(
"{}。",
self.unsupported
.as_ref()
.map(|reason| reason.reason())
.unwrap_or_else(|| "不检查更新".into())
),
match &self.unsupported {
Some(reason) => t!("update.status.unsupported", reason = reason.reason()),
None => t!("update.status.off"),
},
Tone::Plain,
),
Phase::Idle => ("尚未检查更新。".into(), Tone::Plain),
Phase::Checking => ("正在检查更新…".into(), Tone::Plain),
Phase::UpToDate => ("当前已是最新版本。".into(), Tone::Plain),
Phase::Idle => (t!("update.status.idle"), Tone::Plain),
Phase::Checking => (t!("update.status.checking"), Tone::Plain),
Phase::UpToDate => (t!("update.status.up_to_date"), Tone::Plain),
Phase::Available if self.manual.is_some() => (
format!("发现新版本 {offered},需要从官网下载安装。"),
t!("update.status.available_manual", version = version),
Tone::Warning,
),
Phase::Available => match &self.unsupported {
Some(reason) => (
format!("发现新版本 {offered}。{}。", reason.reason()),
t!(
"update.status.available_unsupported",
version = version,
reason = reason.reason()
),
Tone::Warning,
),
None => (format!("发现新版本 {offered}。"), Tone::Plain),
None => (
t!("update.status.available", version = version),
Tone::Plain,
),
},
Phase::Downloading { done, total } => (
format!("正在下载新版本 {offered}… {}%", percent(*done, *total)),
t!(
"update.status.downloading",
version = version,
percent = percent(*done, *total)
),
Tone::Plain,
),
Phase::Verifying => (format!("正在校验新版本 {offered}…"), Tone::Plain),
Phase::Ready => (
format!("新版本 {offered} 已下载,重启 ShellRS 即可完成更新。"),
Phase::Verifying => (
t!("update.status.verifying", version = version),
Tone::Plain,
),
Phase::Ready => (t!("update.status.ready", version = version), Tone::Plain),
Phase::Installing => (
t!("update.status.installing", version = version),
Tone::Plain,
),
Phase::Installing => (format!("正在安装新版本 {offered}…"), Tone::Plain),
Phase::Failed { stage, error } => (
match stage {
Stage::Check => format!("检查更新失败:{error}"),
Stage::Download => format!("下载新版本 {offered} 失败:{error}"),
Stage::Install => format!("安装新版本 {offered} 失败:{error}"),
Stage::Check => t!("update.status.check_failed", error = error),
Stage::Download => {
t!(
"update.status.download_failed",
version = version,
error = error
)
}
Stage::Install => {
t!(
"update.status.install_failed",
version = version,
error = error
)
}
},
Tone::Danger,
),
}
};
(text.to_string(), tone)
}
/// What the version on offer needs next, for the button next to the
@@ -119,24 +147,26 @@ impl UpdateSnapshot {
/// version ready to install, one that has to be downloaded by hand, or
/// automatic downloads that keep failing.
pub fn badge(&self) -> Option<UpdateBadge> {
let offered = self.offered_version()?;
let version = self.offered_version()?;
let version = version.as_str();
let (label, trouble) = match &self.phase {
Phase::Ready => (format!("新版本 {offered} 已就绪"), false),
Phase::Ready => (t!("update.badge.ready", version = version), false),
Phase::Available if self.needs_download_page() => {
(format!("新版本 {offered} 可用"), false)
(t!("update.badge.available", version = version), false)
}
Phase::Failed {
stage: Stage::Download,
..
} if self.download_failures >= super::updater::FAILURES_BEFORE_BADGE => {
(format!("新版本 {offered} 下载失败"), true)
(t!("update.badge.download_failed", version = version), true)
}
Phase::Failed {
stage: Stage::Install,
..
} => (format!("新版本 {offered} 安装失败"), true),
} => (t!("update.badge.install_failed", version = version), true),
_ => return None,
};
let label = label.to_string();
Some(UpdateBadge { label, trouble })
}
@@ -185,45 +215,44 @@ pub struct RestartImpact {
/// 「重启会关闭 2 个远程终端和 1 个 SFTP 标签,停止 1 个传输(保留续传进度)。」,
/// or nothing when a restart interrupts nothing.
pub fn restart_note(impact: RestartImpact) -> Option<String> {
let closes = join(&[
(impact.remote_terminals, "个远程终端"),
(impact.sftp_tabs, "个 SFTP 标签"),
(impact.local_terminals, "个本地终端"),
let closes = phrases(&[
(impact.remote_terminals > 0)
.then(|| tn!("update.restart.remote_terminals", impact.remote_terminals)),
(impact.sftp_tabs > 0).then(|| tn!("update.restart.sftp_tabs", impact.sftp_tabs)),
(impact.local_terminals > 0)
.then(|| tn!("update.restart.local_terminals", impact.local_terminals)),
]);
let stops = join(&[
(impact.transfers, "个传输(保留续传进度)"),
(impact.forwards, "条端口转发"),
let stops = phrases(&[
(impact.transfers > 0).then(|| tn!("update.restart.transfers", impact.transfers)),
(impact.forwards > 0).then(|| tn!("update.restart.forwards", impact.forwards)),
]);
let note = match (closes, stops) {
(None, None) => None,
(Some(closes), None) => Some(format!("重启会关闭 {closes}。")),
(None, Some(stops)) => Some(format!("重启会停止 {stops}。")),
(Some(closes), Some(stops)) => Some(format!("重启会关闭 {closes},停止 {stops}。")),
(Some(closes), None) => Some(t!("update.restart.closes", closes = closes)),
(None, Some(stops)) => Some(t!("update.restart.stops", stops = stops)),
(Some(closes), Some(stops)) => Some(t!(
"update.restart.closes_and_stops",
closes = closes,
stops = stops
)),
};
let unsaved = (impact.unsaved_files > 0).then(|| {
format!(
"{} 个文件有未保存的修改,重启后会丢失。",
impact.unsaved_files
)
});
match (note, unsaved) {
(Some(note), Some(unsaved)) => Some(format!("{note}{unsaved}")),
let unsaved = (impact.unsaved_files > 0)
.then(|| tn!("update.restart.unsaved_files", impact.unsaved_files));
let note = match (note, unsaved) {
(Some(note), Some(unsaved)) => Some(t!(
"update.restart.and_unsaved_files",
note = note,
unsaved = unsaved
)),
(note, unsaved) => note.or(unsaved),
}
};
note.map(|note| note.to_string())
}
/// `2 个远程终端、1 个 SFTP 标签和 1 个本地终端`, leaving out what is none.
fn join(parts: &[(usize, &str)]) -> Option<String> {
let parts: Vec<String> = parts
.iter()
.filter(|(count, _)| *count > 0)
.map(|(count, noun)| format!("{count} {noun}"))
.collect();
match parts.as_slice() {
[] => None,
[one] => Some(one.clone()),
[rest @ .., last] => Some(format!("{}和 {}", rest.join("、"), last)),
}
fn phrases(parts: &[Option<SharedString>]) -> Option<String> {
let parts: Vec<&SharedString> = parts.iter().flatten().collect();
(!parts.is_empty()).then(|| join_list(&parts))
}
#[cfg(test)]
@@ -296,6 +325,67 @@ mod tests {
);
}
#[test]
fn restarting_says_what_it_interrupts_in_english() {
crate::i18n::isolate_thread();
crate::i18n::set_locale("en");
assert_eq!(
restart_note(RestartImpact {
remote_terminals: 2,
sftp_tabs: 1,
local_terminals: 1,
transfers: 1,
forwards: 3,
unsaved_files: 0,
})
.as_deref(),
Some(
"Restarting closes 2 remote terminals, 1 SFTP tab and 1 local terminal \
and stops 1 transfer (its progress is kept) and 3 port forwards."
)
);
assert_eq!(
restart_note(RestartImpact {
sftp_tabs: 2,
unsaved_files: 1,
..RestartImpact::default()
})
.as_deref(),
Some("Restarting closes 2 SFTP tabs. Unsaved changes in 1 file will be lost.")
);
assert_eq!(
restart_note(RestartImpact {
unsaved_files: 3,
..RestartImpact::default()
})
.as_deref(),
Some("Unsaved changes in 3 files will be lost.")
);
assert_eq!(
snapshot(Phase::Downloading {
done: 45,
total: 100
})
.status_line()
.0,
"Downloading ShellRS 0.2.0… 45%"
);
let failed = snapshot(Phase::Failed {
stage: Stage::Download,
error: UpdateError::Http(503),
});
assert_eq!(
failed.status_line().0,
"Couldn’t download ShellRS 0.2.0: the update server answered HTTP 503"
);
let mut off = snapshot(Phase::Off);
off.unsupported = Some(Unsupported::DevelopmentBuild);
assert_eq!(
off.status_line().0,
"Development builds don’t check for updates."
);
}
#[test]
fn each_phase_has_its_line() {
assert_eq!(snapshot(Phase::Checking).status_line().0, "正在检查更新…");
+38 -23
View File
@@ -19,6 +19,7 @@ use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use crate::app::{CatalogIcon, DownloadUpdate, OpenChangelog, OpenDownloadPage, RestartToUpdate};
use crate::i18n::t;
use super::status::{RestartImpact, UpdateStep, percent, restart_note};
use super::updater::{Phase, Stage, UpdateSnapshot, Updater};
@@ -57,8 +58,12 @@ impl Render for UpdateDialog {
.as_ref()
.and_then(|release| release.published_on())
{
Some(day) => format!("当前版本 {} · 新版本发布于 {day}", snapshot.current),
None => format!("当前版本 {}", snapshot.current),
Some(day) => t!(
"update.dialog.versions_released",
current = snapshot.current,
day = day
),
None => t!("update.dialog.current_version", current = snapshot.current),
};
v_flex()
.id("update-dialog")
@@ -73,7 +78,7 @@ impl Render for UpdateDialog {
div()
.id("update-ready")
.test_support()
.child("新版本已下载,重启 ShellRS 即可完成更新。"),
.child(t!("update.dialog.ready")),
)
})
.child(div().text_color(muted).child(versions)),
@@ -122,29 +127,33 @@ impl Render for UpdateDialog {
}
/// The progress line and bar while downloading or checking the package.
fn progress(snapshot: &UpdateSnapshot) -> Option<(String, f32)> {
fn progress(snapshot: &UpdateSnapshot) -> Option<(SharedString, f32)> {
match snapshot.phase {
Phase::Downloading { done, total } => {
let percent = percent(done, total);
Some((format!("正在下载 · {percent}%"), percent as f32))
Some((
t!("update.dialog.downloading", percent = percent),
percent as f32,
))
}
Phase::Verifying => Some(("正在校验…".into(), 100.)),
Phase::Verifying => Some((t!("update.dialog.verifying"), 100.)),
_ => None,
}
}
/// Why the last step failed, or why this copy cannot install the update.
fn problem(snapshot: &UpdateSnapshot) -> Option<String> {
fn problem(snapshot: &UpdateSnapshot) -> Option<SharedString> {
match &snapshot.phase {
Phase::Failed { stage, error } => Some(match stage {
Stage::Check => format!("检查失败:{error}"),
Stage::Download => format!("下载失败:{error}"),
Stage::Install => format!("安装失败:{error}"),
Stage::Check => t!("update.dialog.check_failed", error = error),
Stage::Download => t!("update.dialog.download_failed", error = error),
Stage::Install => t!("update.dialog.install_failed", error = error),
}),
_ if snapshot.manual.is_some() => {
Some("这个版本需要从官网下载安装,无法在 ShellRS 中直接更新。".into())
}
_ => snapshot.unsupported.as_ref().map(|reason| reason.reason()),
_ if snapshot.manual.is_some() => Some(t!("update.dialog.manual")),
_ => snapshot
.unsupported
.as_ref()
.map(|reason| reason.reason().into()),
}
}
@@ -161,8 +170,8 @@ pub fn open_update_dialog(
window.open_dialog(cx, move |dialog, window, cx| {
let snapshot = updater.read(cx).snapshot();
let title = match snapshot.offered_version() {
Some(version) => format!("ShellRS {version}"),
None => "ShellRS 更新".into(),
Some(version) => SharedString::from(format!("ShellRS {version}")),
None => t!("update.dialog.title"),
};
dialog
.title(title)
@@ -179,20 +188,26 @@ pub fn open_update_dialog(
/// Where to read what changed, and the one step that applies now. The
/// dialog closes by its close button or Escape.
fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
let action: Option<(&'static str, &'static str, Box<dyn Action>)> =
let action: Option<(&'static str, SharedString, Box<dyn Action>)> =
snapshot.step().map(|step| -> (_, _, Box<dyn Action>) {
match step {
UpdateStep::Restart => {
("restart-to-update", "重启并安装", Box::new(RestartToUpdate))
}
UpdateStep::Restart => (
"restart-to-update",
t!("update.dialog.restart"),
Box::new(RestartToUpdate),
),
UpdateStep::DownloadPage => (
"open-download-page",
"前往下载页",
t!("update.dialog.download_page"),
Box::new(OpenDownloadPage),
),
UpdateStep::Download { retry } => (
"download-update",
if retry { "重试" } else { "下载" },
if retry {
t!("update.dialog.retry")
} else {
t!("update.dialog.download")
},
Box::new(DownloadUpdate),
),
}
@@ -202,7 +217,7 @@ fn footer(snapshot: &UpdateSnapshot, dispatch: FocusHandle) -> DialogFooter {
.child(
Button::new("open-changelog")
.icon(CatalogIcon::ExternalLink)
.label("查看更新内容")
.label(t!("update.dialog.whats_new"))
.on_click(move |_, window, cx| {
changelog.dispatch_action(&OpenChangelog, window, cx);
}),
+6 -3
View File
@@ -24,6 +24,7 @@ use super::feed::{HttpFeed, UpdateFeed};
use super::install::{Installer, Launch, Relaunch, Staged, Unsupported, system_installer};
use super::manifest::{Offer, Release};
use super::verify::{Opened, TrustedKeys, open_envelope, verify_file};
use crate::i18n::t;
/// The first look, a while after start so it does not compete with the
/// window coming up.
@@ -408,7 +409,7 @@ impl Updater {
this.phase = Phase::Failed {
stage: Stage::Install,
error: UpdateError::Install(
"无法自动重新启动,请手动重新打开 ShellRS".into(),
t!("update.updater.restart_failed").to_string(),
),
};
this.changed(cx);
@@ -576,7 +577,9 @@ impl Updater {
) {
self.phase = Phase::Failed {
stage: Stage::Check,
error: UpdateError::Network("检查意外中止".into()),
error: UpdateError::Network(
t!("update.updater.check_stopped").to_string(),
),
};
self.changed(cx);
}
@@ -794,7 +797,7 @@ pub fn start_handed_over() {
if let Some(launch) = launch
&& let Err(error) = launch.spawn()
{
eprintln!("shellrs: 无法启动更新安装程序:{error}");
eprintln!("shellrs: could not start the update installer: {error}");
}
}
+27 -19
View File
@@ -18,6 +18,7 @@ use crate::cli::{
plan_host, with_saved_credential_secrets, with_saved_passwords,
};
use crate::host::{CredentialId, GroupDraft, GroupId, HostId, HostStore};
use crate::i18n::t;
use crate::secrets::{SecretRef, SharedSecretStore};
use super::workspace_view::Workspace;
@@ -157,7 +158,9 @@ impl Workspace {
.host(id)
.map(|host| (host_details(host, store), host_secrets(host, store)))
})
.ok_or_else(|| CliError::new(ErrorCode::HostNotFound, "主机保存后不见了"))?;
.ok_or_else(|| {
CliError::new(ErrorCode::HostNotFound, t!("cli.change.host_gone"))
})?;
let details = cx
.background_executor()
.spawn(async move {
@@ -180,7 +183,10 @@ impl Workspace {
let store = self.store.read(cx);
let id = find_saved_host(store, host)?;
let Some(host) = store.host(id) else {
return Err(CliError::new(ErrorCode::HostNotFound, "主机已不存在"));
return Err(CliError::new(
ErrorCode::HostNotFound,
t!("cli.change.host_missing"),
));
};
let deleted = HostDeleted {
host: host_details(host, store),
@@ -190,10 +196,7 @@ impl Workspace {
if !force && self.has_tabs(id, cx) {
return Err(CliError::new(
ErrorCode::HostInUse,
format!(
"主机「{}」有打开的标签:先关闭它们,或加 --force 连同标签一起关闭",
deleted.host.name
),
t!("cli.change.host_in_use", name = deleted.host.name),
));
}
self.close_host_tabs(id, window, cx);
@@ -241,7 +244,7 @@ impl Workspace {
)
})
})
.ok_or_else(|| CliError::new(ErrorCode::CredentialNotFound, "凭据保存后不见了"))?;
.ok_or_else(credential_gone)?;
let details = cx
.background_executor()
.spawn(async move {
@@ -310,7 +313,10 @@ fn save_credential(
let path = store
.save_private_key(existing, pasted.text())
.map_err(|error| {
CliError::new(ErrorCode::SaveFailed, format!("私钥未能保存:{error}"))
CliError::new(
ErrorCode::SaveFailed,
t!("cli.change.key_not_saved", error = error),
)
})?;
draft = draft.with_key_path(path);
}
@@ -322,10 +328,7 @@ fn save_credential(
None => store.insert_credential(draft, cx),
});
let Some(credential) = store.credential(id).cloned() else {
return Err(CliError::new(
ErrorCode::CredentialNotFound,
"凭据保存后不见了",
));
return Err(credential_gone());
};
let mut writes = Vec::new();
if let Some(value) = plan.password.value() {
@@ -369,13 +372,18 @@ fn is_saved(secrets: &SharedSecretStore, secret: &SecretRef) -> bool {
secrets.get(secret).is_ok_and(|value| value.is_some())
}
fn save_failed(failures: Vec<SharedString>) -> CliError {
fn credential_gone() -> CliError {
CliError::new(
ErrorCode::SaveFailed,
failures
.iter()
.map(SharedString::as_ref)
.collect::<Vec<_>>()
.join(";"),
ErrorCode::CredentialNotFound,
t!("cli.change.credential_gone"),
)
}
/// Every failure, one after the other.
fn save_failed(failures: Vec<SharedString>) -> CliError {
let message = failures
.into_iter()
.reduce(|before, next| t!("cli.change.failures", before = before, next = next))
.unwrap_or_default();
CliError::new(ErrorCode::SaveFailed, message)
}