在线升级与外部 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:
1 parent
17dc087f52
commit
f6286b776c
20 files changed
+1398
-380
No files matched your search
+392
@@ -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}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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
@@ -46,7 +46,7 @@ fn without_password(url: &str) -> String {
|
||||
}
|
||||
parsed.to_string()
|
||||
}
|
||||
Err(_) => "<无法解析的链接>".into(),
|
||||
Err(_) => "<unreadable link>".into(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+39
-42
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user