diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..35984ef --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# 统一按 LF 处理换行符。 +# +# 为什么需要:这个仓库里 core.autocrlf 默认是 false 且此前没有 .gitattributes, +# git 会原样存盘上的字节。Windows 上某些编辑器保存文件时会顺手改成 CRLF, +# 于是 `git status` 会把整个仓库都标成已修改——实测一次就是 37 个文件、 +# 5608 行增删,而 `git diff --ignore-all-space` 是空的,一行真实内容都没变。 +# 这会淹没真正的改动,评审也没法看。 +# +# text=auto 让 git 在计算对象时把换行符归一成 LF,工作区是 CRLF 也不再算差异; +# eol=lf 保证检出到工作区时也是 LF。 +# +# 对 install 这个文件尤其重要:它是用户 `source` 执行的 shell 脚本, +# 跑在 Linux 上,CRLF 会导致 bad interpreter 之类的报错。 +* text=auto eol=lf + +# 二进制文件不做任何转换。仓库目前没有二进制文件, +# 这几行是防止以后加了图片等资源被误转换。 +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.svg text eol=lf +*.zip binary +*.gz binary +*.tar binary +*.deb binary diff --git a/README.md b/README.md index 57907b0..7246a7b 100644 --- a/README.md +++ b/README.md @@ -4,18 +4,38 @@ ## 工具列表 -已支持工具列表: +已支持工具列表(方括号里是菜单编号): -- 一键安装:ROS(支持ROS和ROS2,树莓派Jetson) [贡献@小鱼](https://github.com/fishros) -- 一键安装:VsCode(支持amd64和arm64) [贡献@小鱼](https://github.com/fishros) -- 一键安装:github桌面版(小鱼常用的github客户端) [贡献@小鱼](https://github.com/fishros) -- 一键安装:nodejs开发环境(通过nodejs可以预览小鱼官网噢) [贡献@小鱼](https://github.com/fishros) -- 一键配置:rosdep(小鱼的rosdepc,又快又好用) [贡献@小鱼](https://github.com/fishros) -- 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择) [贡献@小鱼](https://github.com/fishros) -- 一键配置:系统源(更换系统源,支持全版本Ubuntu系统) [贡献@小鱼](https://github.com/fishros) -- 一键安装:Docker(支持amd64和arm64) [贡献@alyssa](https://github.com/alyssa1024) -- 一键安装:cartographer 贡献 [@小鱼](https://github.com/fishros) & [@Catalpa](https://github.com/Y-zi) -- 一键安装:微信客户端 [贡献@小鱼](https://github.com/fishros) +**ROS安装** +- [1] 一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson) [贡献@小鱼](https://github.com/fishros) +- [11] 一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用) +- [16] 一键安装:系统自带ROS(仅供特殊情况下使用) + +**ROS开发工具** +- [4] 一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错) [贡献@小鱼](https://github.com/fishros) +- [3] 一键安装:rosdepc(小鱼的国内版rosdep,又快又好用) [贡献@小鱼](https://github.com/fishros) +- [22] 一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘、手柄遥控等) +- [9] 一键安装:Cartographer(仅支持ROS1:Ubuntu16/18/20) 贡献 [@小鱼](https://github.com/fishros) & [@Catalpa](https://github.com/Y-zi) +- [12] 一键安装:MicroROS开发环境(PlatformIO,支持FishBot) + +**换源与网络** +- [5] 一键配置:系统源(更换国内源,支持Ubuntu/Debian全版本) [贡献@小鱼](https://github.com/fishros) +- [13] 一键配置:Python国内源 +- [17] 一键配置:Docker代理(支持VPN+代理服务两种模式) +- [14] 一键安装:科学上网代理工具 + +**开发工具** +- [7] 一键安装:VSCode(支持amd64和arm64) [贡献@小鱼](https://github.com/fishros) +- [8] 一键安装:Docker(支持amd64和arm64) [贡献@alyssa](https://github.com/alyssa1024) +- [6] 一键安装:Node.js开发环境 [贡献@小鱼](https://github.com/fishros) +- [2] 一键安装:GitHub桌面版(小鱼常用的GitHub客户端) [贡献@小鱼](https://github.com/fishros) +- [18] 一键安装/卸载:OpenCode(AI编程助手) + +**日常软件** +- [10] 一键安装:微信客户端 [贡献@小鱼](https://github.com/fishros) +- [15] 一键安装:QQ for Linux + +> 菜单编号固定不变(自动化配置按编号选择),新增工具只会用新编号。 @@ -24,6 +44,23 @@ source <(wget -qO- http://fishros.com/install) ``` +装 ROS 在菜单里输入 `1`,接着选安装模式:不知道怎么选的选 `1` 小白模式,确认后脚本会自动识别系统、 +测速换源、挑选可用的镜像和对应的 ROS 版本,中途不用再做选择,装完会列出替你选了什么; +想自己指定镜像源和版本的选 `2` 高手模式。 + +### 用自己的服务器测试新版本 + +把仓库文件放到任意 HTTP 服务器上,把 `install` 第一行改成你的地址,然后在测试机上运行即可, +后续所有工具脚本都会从这个地址下载: + +``` +# 服务器上(仓库目录内) +sed -i "1s#.*#export FISHROS_URL=http://<服务器IP>:8080/#" install +python3 -m http.server 8080 +# 测试机上 +wget http://<服务器IP>:8080/install -O fishros && . fishros +``` + ## 如何自动选择(Dockerfile中使用) 目前一键安装支持从配置文件自动输入选项,你需要手动运行一次一键安装,使用完毕后会自动产生 `/tmp/fish_install.yaml`。 diff --git a/docs/_sidebar.md b/docs/_sidebar.md index cbe55fa..05bb8df 100755 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -1,3 +1,3 @@ -- [一键安装](./README.md) -- 高效工具 +- [一键安装](./README.md) +- 高效工具 - [Md编辑器Typora](./install/Typora.md) \ No newline at end of file diff --git a/install b/install index c5b078f..d51a3b9 100644 --- a/install +++ b/install @@ -1,15 +1,29 @@ export FISHROS_URL=http://mirror.fishros.com/install/ +SUDO='' +if [ "$(id -u)" -ne 0 ];then + SUDO='sudo' +fi + +# 上次运行被中断时 /tmp/fishinstall 会残留且属主是 root, 普通用户再下载会因权限不足静默失败, +# 结果要么没有菜单直接退出, 要么跑的是旧脚本, 所以每次先清掉 +$SUDO rm -rf /tmp/fishinstall/ mkdir -p /tmp/fishinstall/tools/translation/assets -wget $FISHROS_URL/install.py -O /tmp/fishinstall/install.py 2>>/dev/null +# 与 install.py 里其它下载保持一致不校验证书: 老系统证书库过旧、树莓派等开机时钟不准时, 服务器跳转到 https 也能下载 +wget -q -T 15 -t 3 --no-check-certificate $FISHROS_URL/install.py -O /tmp/fishinstall/install.py source /etc/profile -SUDO='' -if [ $UID -ne 0 ];then - SUDO='sudo' +if [ -s /tmp/fishinstall/install.py ]; then + # 已经能导入就不再跑 apt: 每次都要读软件包列表(约 1s), 系统自动更新占着 apt 锁时还会卡住 + if ! python3 -c "import distro, yaml" 2> /dev/null; then + $SUDO apt install python3-distro python3-yaml -y + fi + # sudo 默认会清掉环境变量, 显式把 FISHROS_URL 传进去, 否则后续工具一律从默认地址下载(自建服务器测试时会拿到旧版) + $SUDO env FISHROS_URL="$FISHROS_URL" python3 /tmp/fishinstall/install.py +else + echo "下载一键安装脚本失败: ${FISHROS_URL}install.py" + echo "请检查网络后重试(能否打开 ${FISHROS_URL}install.py)" fi -$SUDO apt install python3-distro python3-yaml -y -$SUDO python3 /tmp/fishinstall/install.py $SUDO rm -rf /tmp/fishinstall/ if [ -f fishros ]; then diff --git a/install.py b/install.py index 4108a8a..3e2f6b5 100644 --- a/install.py +++ b/install.py @@ -1,147 +1,177 @@ # -*- coding: utf-8 -*- import os import importlib +import subprocess url_prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") base_url = os.path.join(url_prefix, "tools/base.py") translator_url = os.path.join(url_prefix, "tools/translation/translator.py") -INSTALL_ROS = 0 # 安装ROS相关 -INSTALL_SOFTWARE = 1 # 安装软件 -CONFIG_TOOL = 2 # 配置相关 -INSTALL_AI = 3 # AI相关 +# 菜单分组, 显示顺序与下面 tools_type_map 的书写顺序一致 +CAT_ROS_INSTALL = 0 +CAT_ROS_TOOLS = 1 +CAT_SOURCE_NETWORK = 2 +CAT_DEV_TOOLS = 3 +CAT_DAILY = 4 tools_type_map = { - INSTALL_ROS: "ROS相关", - INSTALL_AI: "AI板块", - INSTALL_SOFTWARE: "常用软件", - CONFIG_TOOL: "配置工具", + CAT_ROS_INSTALL: "ROS安装", + CAT_ROS_TOOLS: "ROS开发工具", + CAT_SOURCE_NETWORK: "换源与网络", + CAT_DEV_TOOLS: "开发工具", + CAT_DAILY: "日常软件", } - +# 菜单编号是对外接口(fish_install.yaml / Dockerfile 里按编号自动选择), 只能新增不能改号; +# 菜单里按本字典的书写顺序展示, 所以调整顺序只需要挪位置, 不要改编号 tools = { + # ---------------- ROS安装 ---------------- + # 小白模式已并入菜单 1, 这里只保留条目供菜单 1 下载依赖, 不在菜单里展示 + 21: { + "tip": "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)", + "type": CAT_ROS_INSTALL, + "tool": "tools/tool_install_ros_auto.py", + "dep": [1, 4, 5], + "hidden": True, + }, 1: { - "tip": "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)", - "type": INSTALL_ROS, + "tip": "一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson)", + "type": CAT_ROS_INSTALL, "tool": "tools/tool_install_ros.py", - "dep": [4, 5], + "dep": [4, 5, 21], + "highlight": True, }, - 2: { - "tip": "一键安装:github桌面版(小鱼常用的github客户端)", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_github_desktop.py", - "dep": [], + 11: { + "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)", + "type": CAT_ROS_INSTALL, + "tool": "tools/tool_install_ros_with_docker.py", + "dep": [7, 8], }, - 3: { - "tip": "一键安装:rosdep(小鱼的rosdepc,又快又好用)", - "type": INSTALL_ROS, - "tool": "tools/tool_config_rosdep.py", - "dep": [], + 16: { + "tip": "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)", + "type": CAT_ROS_INSTALL, + "tool": "tools/tool_install_ros1_systemdefault.py", + "dep": [5], }, + # ---------------- ROS开发工具 ---------------- 4: { - "tip": "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择)", - "type": INSTALL_ROS, + "tip": "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错)", + "type": CAT_ROS_TOOLS, "tool": "tools/tool_config_rosenv.py", "dep": [], }, - 5: { - "tip": "一键配置:系统源(更换系统源,支持全版本Ubuntu系统)", - "type": CONFIG_TOOL, - "tool": "tools/tool_config_system_source.py", - "dep": [1], - }, - 6: { - "tip": "一键安装:NodeJs环境", - "type": INSTALL_AI, - "tool": "tools/tool_install_nodejs.py", - "dep": [], - }, - 7: { - "tip": "一键安装:VsCode开发工具", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_vscode.py", + 3: { + "tip": "一键安装:rosdepc(小鱼的国内版rosdep,又快又好用)", + "type": CAT_ROS_TOOLS, + "tool": "tools/tool_config_rosdep.py", "dep": [], }, - 8: { - "tip": "一键安装:Docker", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_docker.py", - "dep": [], + 22: { + "tip": "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘、手柄遥控等)", + "type": CAT_ROS_TOOLS, + "tool": "tools/tool_install_ros_pkgs.py", + "dep": [1], }, 9: { - "tip": "一键安装:Cartographer(18 20测试通过,16未测. updateTime 20240125)", - "type": INSTALL_ROS, + "tip": "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)", + "type": CAT_ROS_TOOLS, "tool": "tools/tool_install_cartographer.py", "dep": [3], }, - 10: { - "tip": "一键安装:微信(可以在Linux上使用的微信)", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_wechat.py", - "dep": [8], - }, - 11: { - "tip": "一键安装:ROS Docker版(支持所有版本ROS/ROS2)", - "type": INSTALL_ROS, - "tool": "tools/tool_install_ros_with_docker.py", - "dep": [7, 8], - }, 12: { - "tip": "一键安装:PlateformIO MicroROS开发环境(支持Fishbot)", - "type": INSTALL_SOFTWARE, + "tip": "一键安装:MicroROS开发环境(PlatformIO,支持FishBot)", + "type": CAT_ROS_TOOLS, "tool": "tools/tool_install_micros_fishbot_env.py", "dep": [], }, + # ---------------- 换源与网络 ---------------- + 5: { + "tip": "一键配置:系统源(更换国内源,支持Ubuntu/Debian全版本)", + "type": CAT_SOURCE_NETWORK, + "tool": "tools/tool_config_system_source.py", + "dep": [1], + }, 13: { - "tip": "一键配置:python国内源", - "type": CONFIG_TOOL, + "tip": "一键配置:Python国内源(加速pip安装)", + "type": CAT_SOURCE_NETWORK, "tool": "tools/tool_config_python_source.py", "dep": [], }, + 17: { + "tip": "一键配置:Docker代理(支持VPN+代理服务两种模式)", + "type": CAT_SOURCE_NETWORK, + "tool": "tools/tool_config_docker_proxy.py", + "dep": [], + }, 14: { "tip": "一键安装:科学上网代理工具", - "type": INSTALL_AI, + "type": CAT_SOURCE_NETWORK, "tool": "tools/tool_install_proxy_tool.py", "dep": [8], }, - 15: { - "tip": "一键安装:QQ for Linux", - "type": INSTALL_SOFTWARE, - "tool": "tools/tool_install_qq.py", + # ---------------- 开发工具 ---------------- + 7: { + "tip": "一键安装:VSCode(代码编辑器)", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_vscode.py", "dep": [], }, - 16: { - "tip": "一键安装:系统自带ROS (!!警告!!仅供特殊情况下使用)", - "type": INSTALL_ROS, - "tool": "tools/tool_install_ros1_systemdefault.py", - "dep": [5], + 8: { + "tip": "一键安装:Docker", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_docker.py", + "dep": [], }, - 17: { - "tip": "一键配置: Docker代理(支持VPN+代理服务两种模式)", - "type": CONFIG_TOOL, - "tool": "tools/tool_config_docker_proxy.py", + 6: { + "tip": "一键安装:Node.js环境", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_nodejs.py", + "dep": [], + }, + 2: { + "tip": "一键安装:GitHub桌面版(小鱼常用的GitHub客户端)", + "type": CAT_DEV_TOOLS, + "tool": "tools/tool_install_github_desktop.py", "dep": [], }, 18: { - "tip": "一键安装/卸载:OpenCode(AI编程助手)", - "type": INSTALL_AI, + "tip": "一键安装/卸载:AI编程工具(OpenCode/Codex/Claude Code/DeepSeek/Pi/CC Switch)", + "type": CAT_DEV_TOOLS, "tool": "tools/tool_install_opencode.py", "dep": [6], }, # 19: { # "tip": "一键清理:OpenClaw(可选保留配置)", - # "type": INSTALL_AI, + # "type": CAT_DEV_TOOLS, # "tool": "tools/tool_config_openclaw_cleanup.py", # "dep": [], # }, # 20: { # "tip": "一键安装:OpenClaw(智能AI助手)", - # "type": INSTALL_AI, + # "type": CAT_DEV_TOOLS, # "tool": "tools/tool_install_openclaw.py", # "dep": [6], # }, + # ---------------- 日常软件 ---------------- + 10: { + "tip": "一键安装:微信(可以在Linux上使用的微信)", + "type": CAT_DAILY, + "tool": "tools/tool_install_wechat.py", + "dep": [8], + }, + 15: { + "tip": "一键安装:QQ for Linux", + "type": CAT_DAILY, + "tool": "tools/tool_install_qq.py", + "dep": [], + }, + 23: { + "tip": "一键安装:Chrome浏览器(强烈推荐下载设置成默认浏览器)", + "type": CAT_DAILY, + "tool": "tools/tool_install_chrome.py", + "dep": [], + }, } -# # 创建用于存储不同类型工具的字典,按目录顺序展示 @@ -149,6 +179,8 @@ # 遍历tools字典,根据type值进行分类 for tool_id, tool_info in tools.items(): + if tool_info.get("hidden"): + continue tool_type = tool_info["type"] tool_categories[tool_type][tool_id] = tool_info @@ -162,16 +194,14 @@ def main(): os.system("mkdir -p /tmp/fishinstall/tools/translation/assets") url_prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") if url_prefix: - os.system( - "wget {} -O /tmp/fishinstall/{} --no-check-certificate".format( - base_url, base_url.replace(url_prefix, "") - ) - ) - os.system( - "wget {} -O /tmp/fishinstall/{} --no-check-certificate".format( - translator_url, translator_url.replace(url_prefix, "") - ) - ) + # 静默下载, 只在失败时提示, 免得进菜单前刷一屏 wget 进度 + for url in (base_url, translator_url): + if os.system( + "wget -q {} -O /tmp/fishinstall/{} -T 15 -t 3 --no-check-certificate".format( + url, url.replace(url_prefix, "") + ) + ): + print("下载失败, 请检查网络后重试: {}".format(url)) from tools.base import ( CmdTask, @@ -180,6 +210,9 @@ def main(): ChooseTask, ChooseWithCategoriesTask, Tracking, + AdUtils, + StatUtils, + BaseTool, ) from tools.base import encoding_utf8, osversion, osarch from tools.base import run_tool_file, download_tools @@ -192,13 +225,19 @@ def main(): global tracing tracing = copy.copy(Tracking) - # 使用量统计 - CmdTask( - "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 && rm -rf /tmp/t1733" - ).run() + # 使用量统计: 官网按天计启动次数(后台线程, 失败忽略), 论坛 1733 帖子的访问照旧保留 + # 论坛这次访问只为计数, 不用等它返回, 交给后台进程(一键安装退出后它会自己结束) + StatUtils.report("run") + subprocess.Popen( + "wget https://fishros.org.cn/forum/topic/1733 -O /tmp/t1733 -q --no-check-certificate --timeout 10 -t 2 && rm -rf /tmp/t1733", + shell=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) PrintUtils.print_success(tr.tr("已为您切换语言至当前所在国家语言:") + tr.lang) - if tr.country != "CN": + # 只有使用官方镜像站时才切到 GitHub; 自建服务器(FISHROS_URL 指向别处)测试时保持原地址 + if tr.country != "CN" and "mirror.fishros.com" in url_prefix: PrintUtils.print_success( tr.tr( "检测到当前不在CN,切换服务地址为:https://raw.githubusercontent.com/fishros/install/master/" @@ -216,6 +255,12 @@ def main(): print("Solutions: https://fishros.org.cn/forum/topic/24 ") return False PrintUtils.print_success(tr.tr("基础检查通过...")) + # 把系统和架构打出来: 用户发截图求助时一眼能看到是什么系统(dpkg 查到的架构原来会被进度行盖掉) + try: + PrintUtils.print_info(tr.tr("当前系统:") + " {} {} ({}) {}".format( + osversion.get_name(), osversion.get_version(), osversion.get_codename(), osarch)) + except Exception: + pass book = tr.tr(""" .-~~~~~~~~~-._ _.-~~~~~~~~~-. @@ -237,22 +282,34 @@ def main(): 如果觉得工具好用,请给个star,如果你想和小鱼一起编写工具,请关注B站/公众号<鱼香ROS>,联系小鱼 更多工具教程,请访问鱼香ROS官方网站:http://fishros.com """) + # 菜单最上面先放一行商品链接和公众号提示(不放二维码), 内容来自官网 /api/install/ads + AdUtils.show("welcome") PrintUtils.print_delay(tip, 0.001) PrintUtils.print_delay(book, 0.001) # download tools - code, result = ChooseWithCategoriesTask( - tool_categories, - tips=tr.tr("---众多工具,等君来用---"), - categories=tools_type_map, - ).run() - if code == 0: - PrintUtils().print_success( - tr.tr("是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~") - ) - else: + while True: + code, result = ChooseWithCategoriesTask( + tool_categories, + tips=tr.tr("---众多工具,等君来用---"), + categories=tools_type_map, + ).run() + if code == 0: + PrintUtils().print_success( + tr.tr("是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~") + ) + break + StatUtils.report("menu:{}".format(code)) if url_prefix: download_tools(code, tools, url_prefix) - run_tool_file(tools[code]["tool"].replace("/", ".")) + ChooseTask.last_back = False + ret = run_tool_file(tools[code]["tool"].replace("/", ".")) + # 在工具里选了 [0] 返回上一步退出来的, 回到主菜单重新选 + if ret == BaseTool.BACK or ChooseTask.last_back: + PrintUtils.print_info(tr.tr("已返回主菜单")) + continue + if ret == False: + AdUtils.mark_failed() + break # 检查是否在 GitHub Actions 环境中运行或使用了测试配置文件 # 如果是,则跳过生成配置文件和后续的打印操作,因为这些操作需要用户输入 @@ -261,24 +318,32 @@ def main(): and os.environ.get("FISH_INSTALL_CONFIG") is None ): config_helper.gen_config_file() - PrintUtils.print_delay( - tr.tr("欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)"), 0.05 - ) - PrintUtils.print_delay( - tr.tr( - "鱼香小铺正式开业,最低599可入手一台能建图会导航的移动机器人,淘宝搜店:鱼香ROS 或打开链接查看:https://item.taobao.com/item.htm?id=696573635888" - ), - 0.001, - ) - PrintUtils.print_delay( - tr.tr( - "如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈" - ), - 0.001, - ) + # 推广内容从官网 /api/install/ads 读取, 见 tools/base.py 的 AdUtils + AdUtils.show("finish_shop") + AdUtils.show("finish") + StatUtils.flush() + + +def ensure_utf8(): + """ + 终端编码不是 UTF-8(LANG 没设置、GBK 等)时, 打印中文会出错. 原来的做法是装中文语言包后让用户重跑, + 但语言包没生效时就会一直提示 Finish! Please Try Again!(社区帖 24). + 改为直接用系统自带的 C.UTF-8 重新启动自己; 重启后仍不是 UTF-8 才走原来的语言包流程 + """ + import locale + import sys + + if "UTF" in locale.getpreferredencoding().upper() or os.environ.get("FISHROS_UTF8_RESTARTED"): + return + print("Terminal encoding is not UTF-8, restarting with C.UTF-8 ...") + sys.stdout.flush() + env = dict(os.environ, LANG="C.UTF-8", LC_ALL="C.UTF-8", PYTHONIOENCODING="utf-8") + env.update(PYTHONUTF8="1", FISHROS_UTF8_RESTARTED="1") + os.execve(sys.executable, [sys.executable] + sys.argv, env) if __name__ == "__main__": + ensure_utf8() run_exc = [] try: diff --git a/tests/fish_install_test.yaml b/tests/fish_install_test.yaml index c79959b..d846aca 100644 --- a/tests/fish_install_test.yaml +++ b/tests/fish_install_test.yaml @@ -12,6 +12,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 1, desc: "中科大源" } - { choose: 5, desc: "支持的第5个ros版本" } # 中科大,清华,华为,中山大学源不支持Ubuntu18.04安装ros2-bouncy 其余ros2没测试 @@ -21,6 +22,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 2, desc: "清华源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -30,6 +32,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 3, desc: "华为源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -39,6 +42,7 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 4, desc: "中山大学源" } - { choose: 5, desc: "支持的第5个ros版本" } @@ -48,8 +52,9 @@ target_os_version: "bionic" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 5, desc: "支持的第5个ros版本" } - { choose: 2, desc: "基础版(小)" } @@ -59,8 +64,9 @@ target_os_version: "focal" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "foxy(ROS2)" } - { choose: 2, desc: "基础版(小)" } @@ -68,16 +74,19 @@ target_os_version: "focal" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 3, desc: "noetic(ROS1)" } - { choose: 2, desc: "基础版(小)" } # - name: "Install_ROS_focal_foxy" # target_os_version: "focal" # chooses: # - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } +# - { choose: 2, desc: "高手模式" } + - { choose: 2, desc: "高手模式" } # - { choose: 2, desc: "不更换系统源再继续安装" } -# - { choose: 4, desc: "ROS官方源" } +# - { choose: 6, desc: "ROS官方源" } # - { choose: 1, desc: "支持的第一个ros版本" } # - { choose: 2, desc: "基础版(小)" } @@ -87,8 +96,9 @@ target_os_version: "jammy" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } @@ -98,8 +108,9 @@ target_os_version: "noble" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - - { choose: 5, desc: "ROS官方源" } + - { choose: 6, desc: "ROS官方源" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } @@ -109,7 +120,17 @@ target_os_version: "resolute" chooses: - { choose: 1, desc: "一键安装(推荐):ROS(支持ROS/ROS2,树莓派Jetson)" } + - { choose: 2, desc: "高手模式" } - { choose: 2, desc: "不更换系统源再继续安装" } - { choose: 1, desc: "ROS国内镜像源(优先第一个)" } - { choose: 1, desc: "支持的第一个ros版本" } - { choose: 2, desc: "基础版(小)" } + +################################################################################################################################################################## +# 小白模式(菜单 1 里选 1): 只需确认一次, 系统源/ROS源/版本全部自动 +- name: "Install_ROS_focal_auto" + target_os_version: "focal" + chooses: + - { choose: 1, desc: "一键安装(推荐):ROS" } + - { choose: 1, desc: "小白模式" } + - { choose: 1, desc: "开始全自动安装" } diff --git a/tools/base.py b/tools/base.py index 89e5114..9f6b1f5 100644 --- a/tools/base.py +++ b/tools/base.py @@ -5,6 +5,7 @@ import sys import time import subprocess +import shutil import importlib import locale from queue import Queue @@ -24,7 +25,9 @@ print("WARN:No Yaml Module!") encoding = locale.getpreferredencoding() -encoding_utf8 = encoding.find("UTF") > -1 +# Python 3.11+ 在 C/POSIX 区域设置下自动切到 UTF-8 时返回的是小写 "utf-8", 要忽略大小写判断, +# 否则会误判为不支持 UTF-8, 一直提示 Finish! Please Try Again! +encoding_utf8 = "UTF" in encoding.upper() tr = None @@ -40,7 +43,15 @@ def __init__(self, record_file=None): self.record_file = os.environ.get( "FISH_INSTALL_CONFIG", "./fish_install.yaml" ) + # 每次运行结束都会把本次选择记到 /tmp/fish_install.yaml, 在 /tmp 下运行时默认路径正好指向这份记录, + # 就会不声不响地按上次的选项自动执行. 这份记录只在用 FISH_INSTALL_CONFIG 明确指定时才读 + if "FISH_INSTALL_CONFIG" not in os.environ and os.path.realpath( + self.record_file + ) == os.path.realpath("/tmp/fish_install.yaml"): + self.record_file = "" self.default_input_queue = self.get_default_queue(self.record_file) + if self.default_input_queue.qsize() > 0: + print("检测到配置文件 {}, 将按其中的选项自动选择".format(self.record_file)) def record_input(self, item): self.record_input_queue.put(item) @@ -151,16 +162,36 @@ def GetOsVersion(): # to be removed after Ubuntu Xenial is out of support import sys - # @TODO 系统的python版本小于3.8,Conda版本>3.8 - if sys.version_info >= (3, 8): - import distro - else: - import platform as distro import locale import os import platform import subprocess + class _OsReleaseDistro: + """python3-distro 装不上时(apt 源坏了/conda 环境)的兜底, 直接读 /etc/os-release""" + + @staticmethod + def linux_distribution(full_distribution_name=False): + info = {} + for path in ("/etc/os-release", "/usr/lib/os-release"): + if os.path.exists(path): + with codecs.open(path, "r", encoding="utf-8") as f: + for line in f: + key, _, val = line.strip().partition("=") + info[key] = val.strip('"') + break + codename = info.get("VERSION_CODENAME") or info.get("UBUNTU_CODENAME", "") + return (info.get("ID", ""), info.get("VERSION_ID", ""), codename) + + # @TODO 系统的python版本小于3.8,Conda版本>3.8 + if sys.version_info >= (3, 8): + try: + import distro + except ImportError: + distro = _OsReleaseDistro + else: + import platform as distro + def _read_stdout(cmd): try: pop = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) @@ -255,7 +286,7 @@ def __init__(self, lsb_name, get_version_fn=None): elif hasattr(distro, "dist"): self.lsb_info = distro.dist() else: - self.lsb_info = None + self.lsb_info = _OsReleaseDistro.linux_distribution() def is_os(self): if self.lsb_info is None: @@ -289,7 +320,13 @@ def get_codename(self): return "stretch" if v.startswith("10."): return "buster" - return "" + # Debian 11 起版本号不再带小数点, 直接取 lsb/os-release 里的代号 + codename = (self.lsb_info[2] or "").split(" ")[0].lower() + if codename: + return codename + return {"11": "bullseye", "12": "bookworm", "13": "trixie"}.get( + v.split(".")[0], "" + ) class FdoDetect(OsDetector): """ @@ -884,6 +921,11 @@ def print_success(data, end="\n"): def print_warn(data, end="\n"): PrintUtils.print_text("\033[33m{}\033[37m".format(data), end=end) + @staticmethod + def print_highlight(data, end="\n"): + # 淡黄色(256 色里的 229), 用于「安装成功」这类要让用户一眼看到的好消息 + PrintUtils.print_text("\033[38;5;229m{}\033[37m".format(data), end=end) + @staticmethod def print_text(values="", end="\n", flush=False): print(values, end=str(end), flush=flush) # force to string @@ -985,29 +1027,42 @@ def __init__( executable="/bin/sh", ) -> None: super().__init__(Task.TASK_TYPE_CMD) - self.command = command + self.command = CmdTask.strip_sudo_if_needed(command) self.timeout = timeout self.os_command = os_command self.cwd = path self.executable = executable + @staticmethod + def strip_sudo_if_needed(command): + """ + Docker 容器里一般是 root 且没有装 sudo, 这时去掉命令里的 sudo 前缀, 否则一律 command not found + """ + if not isinstance(command, str) or os.geteuid() != 0: + return command + if shutil.which("sudo"): + return command + return re.sub(r"(^|[|;&(]\s*)sudo\s+(?:-E\s+)?", r"\1", command) + def getlog(self, callback=None): - stdout_line = "" - for line in iter(self.sub.stdout.readline, "b"): + # stdout 和 stderr 要同时读: 原来读完 stdout 才读 stderr, 子进程往 stderr 写满管道缓冲(64KB)后 + # 会阻塞不退出, stdout 也就一直不结束, 两边互相等待. apt 一次装近千个包时实测会这样卡死 + def read_stderr(): + for line in iter(self.sub.stderr.readline, ""): + line = line.rstrip() + if callback and line: + callback(line, "err") + + err_thread = threading.Thread(target=read_stderr) + err_thread.daemon = True + err_thread.start() + + for line in iter(self.sub.stdout.readline, ""): line = line.rstrip() # .decode('utf8', errors="ignore") if callback and line: callback(line, "out") - if subprocess.Popen.poll(self.sub) is not None: - if line == "": - break - - for line in iter(self.sub.stderr.readline, "b"): - line = line.rstrip() # .decode('utf8', errors="ignore") - if callback and line: - callback(line, "err") - if subprocess.Popen.poll(self.sub) is not None: - if line == "": - break + self.sub.wait() + err_thread.join(timeout=5) def getlogs(self): out = [] @@ -1042,6 +1097,7 @@ def command_thread(self, executable="/bin/sh"): universal_newlines=True, ) self.bar = Progress() + self.sub_started.set() err = [] out = [] @@ -1070,18 +1126,17 @@ def log_callback(log, log_type): self.ret_ok = True def run_command(self, executable="/bin/sh"): + self.sub_started = threading.Event() self.command_thread = threading.Thread(target=self.command_thread) self.command_thread.start() - time.sleep(0.5) # 等待线程启动 + # 等子进程创建好就开始轮询. 原来固定 sleep(0.5), 哪怕 mkdir 这种命令也要白等半秒, 光启动阶段就累计 3~4 秒 + while not self.sub_started.wait(0.01) and self.command_thread.is_alive(): + pass while self.is_command_finish() == -1: self.bar.update_time() - time.sleep(0.1) + time.sleep(0.05) - start_time = time.time() - while ( - not self.ret_ok and time.time() - start_time < 2.0 - ): # 2s timeout wait command_thread end - time.sleep(0.1) + self.command_thread.join(2.0) # 2s timeout wait command_thread end Tracking.put_cmd_result(self.ret_code, self.ret_out, self.ret_err, self.command) return (self.ret_code, self.ret_out, self.ret_err) @@ -1109,6 +1164,9 @@ def run(self): class ChooseTask(Task): + # 最近一次选择是不是 [0] 返回上一步. 工具因此退出时, install.py 会回到主菜单而不是结束一键安装 + last_back = False + def __init__(self, dic, tips, array=False) -> None: self.tips = tips self.dic = dic @@ -1125,8 +1183,8 @@ def __choose(data, tips, array): count += 1 else: dic = data - dic[0] = "quit" - # 0 quit + # 0 统一表示返回上一步: 多步流程退回前一个问题, 第一个问题退回上一级菜单 + dic[0] = "返回上一步" choose = -1 for key in dic: PrintUtils.print_delay("[{}]:{}".format(key, tr.tr(dic[key])), 0.005) @@ -1160,6 +1218,7 @@ def __choose(data, tips, array): choose = int(choose) break config_helper.record_choose({"choose": choose, "desc": dic[choose]}) + ChooseTask.last_back = choose == 0 PrintUtils.print_fish() return choose, dic[choose] @@ -1188,15 +1247,16 @@ def __choose(data, tips, array, categories): # 打印不同类型工具的分类结果 for tool_type, tools_list in dic.items(): PrintUtils.print_delay("{}:".format(tr.tr(categories[tool_type])), 0.005) - sortkeys = sorted(tools_list.keys()) - for tool_id in sortkeys: - PrintUtils.print_delay( - " [{}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])), - 0.005, - ) + # 按 install.py 里的书写顺序展示, 方便把推荐项排在前面 + for tool_id in tools_list.keys(): + item = " [{:>2}]:{}".format(tool_id, tr.tr(tools_list[tool_id]["tip"])) + if tools_list[tool_id].get("highlight"): + # 推荐项用加粗的亮黄色, 一眼就能看到 + item = "\033[1;93m{}\033[0m".format(item) + PrintUtils.print_delay(item, 0.005) tool_ids.append(tool_id) print() - PrintUtils.print_delay("[0]:quit\n", 0.005) + PrintUtils.print_delay("[0]:{}\n".format(tr.tr("退出")), 0.005) choose = None choose_item = config_helper.get_input_value() @@ -1419,13 +1479,201 @@ def check_result(result, patterns: list): line = str(line) # 确保 line 是字符串 # 对每个 pattern 进行检查 for pattern in patterns: - # 如果找到一个匹配 - if len(re.findall(pattern, line)) == 1: + # 如果找到匹配(输出被拼成一整段, 同一错误往往出现多次, 不能要求恰好一次) + if re.search(pattern, line): return True # 返回 True 表示匹配成功 # 如果所有行和所有模式都未匹配,返回 False return False +class DockerUtils: + # 国内直连 Docker Hub 不通, 拉镜像要靠加速地址. 2026-10 实测公开的加速地址里只有这个华为云的还稳定可用 + # (dockerpull.com / docker.fxxk.dedyn.io 已失效, DaoCloud 只放行部分镜像, 对 fishros2/ros 返回 403) + registry_mirror = "https://ac77d18f43e44819b553040f294b8f9b.mirror.swr.myhuaweicloud.com" + daemon_json = "/etc/docker/daemon.json" + # 放在生成的容器控制脚本(wechat、ROS 容器名等命令)开头: 刚装完 Docker 时加入 docker 组要重新登录才生效, + # 当前终端不加 sudo 执行 docker 会报 permission denied, 用户看不懂, 这里先给出提示 + permission_check_sh = """if [ -S /var/run/docker.sock ] && [ ! -w /var/run/docker.sock ]; then + echo "当前终端还没有使用Docker的权限: 刚装完Docker时需要注销重新登录(或重启)才会生效" + echo "也可以先执行 newgrp docker, 再运行本命令" + exit 1 +fi +""" + + @staticmethod + def set_registry_mirror(url): + """写入 daemon.json 的 registry-mirrors 并重启 Docker, 保留文件里原有的其它配置""" + import json + + config = {} + try: + with open(DockerUtils.daemon_json, "r") as f: + config = json.load(f) + except Exception: + config = {} + if not isinstance(config, dict): + config = {} + config["registry-mirrors"] = [url] + CmdTask("sudo mkdir -p /etc/docker").run() + with open(DockerUtils.daemon_json, "w") as f: + json.dump(config, f, indent=2) + CmdTask("sudo systemctl daemon-reload").run() + return CmdTask("sudo systemctl restart docker").run()[0] == 0 + + @staticmethod + def has_registry_mirror(): + import json + + try: + with open(DockerUtils.daemon_json, "r") as f: + return len(json.load(f).get("registry-mirrors", [])) > 0 + except Exception: + return False + + +class DownloadUtils: + """ + 从多个下载源里挑能用的下大文件(GitHub Releases 的安装包等), 带进度条、实时速度和剩余时间. + 国内直连 GitHub 下载基本下不动, 一般按「公共加速站 → 兜底源」排列 + """ + + # 2026-11 国内服务器实测可用的 GitHub 加速站, 快但时好时坏, 有时会拒绝某些仓库 + github_mirrors = [ + ("ghfast.top 加速", "https://ghfast.top/https://github.com/"), + ("gh-proxy.com 加速", "https://gh-proxy.com/https://github.com/"), + ] + # 每个源先下 15 秒, 平均速度达到 200KB/s 就用它下完; 都达不到时, 用测下来最快的那个源下完 + # (不能简单地「慢了就换下一个」: 实测 gh-proxy.com 89KB/s 被放弃, 最后落到只有 15KB/s 的源, 反而更慢) + slow_check_seconds = 15 + slow_speed = 200 * 1024 + + @staticmethod + def github_sources(repo, tag, file_name): + """GitHub Releases 文件的各个下载地址: 加速站在前, GitHub 直连最后""" + path = "{}/releases/download/{}/{}".format(repo, tag, file_name) + return [(name, prefix + path) for name, prefix in DownloadUtils.github_mirrors] + \ + [("GitHub 直连", "https://github.com/" + path)] + + @staticmethod + def github_latest_tag(repo): + """ + 读 github.com//releases/latest 的跳转地址得到最新版本号(如 v4.0.24), 失败返回 None. + 这个请求很小, 国内直连也能拿到; 直连不通再走加速站 + """ + import urllib.error + import urllib.request + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + opener = urllib.request.build_opener(NoRedirect, urllib.request.ProxyHandler({})) + prefixes = ["https://github.com/"] + [prefix for _, prefix in DownloadUtils.github_mirrors] + for prefix in prefixes: + try: + opener.open(urllib.request.Request(prefix + repo + "/releases/latest", method="HEAD"), timeout=10) + except urllib.error.HTTPError as e: + match = re.search(r"/releases/tag/([^/?#]+)$", e.headers.get("Location") or "") + if match: + return match.group(1) + except Exception: + pass + return None + + @staticmethod + def format_speed(speed): + if speed >= 1048576: + return "{:.1f}MB/s".format(speed / 1048576.0) + return "{:.0f}KB/s".format(speed / 1024.0) + + @staticmethod + def draw_progress(done, total, speed): + """在同一行画进度条: [████████░░░░░░░░] 45% 17.5/38.7MB 2.4MB/s 剩余 9s""" + if total: + left = (total - done) / speed if speed > 0 else -1 + eta = "--" if left < 0 else ("{}m{:02d}s".format(int(left) // 60, int(left) % 60) if left >= 60 else "{}s".format(int(left))) + info = " {:3.0f}% {:.1f}/{:.1f}MB {} 剩余 {}".format( + done * 100.0 / total, done / 1048576.0, total / 1048576.0, DownloadUtils.format_speed(speed), eta) + # 进度条宽度随终端宽度变化; info 里的「剩余」两个汉字各占 2 列, 多留几列余量免得折行 + width = max(10, min(40, shutil.get_terminal_size((80, 20)).columns - len(info) - 10)) + filled = int(width * done / total) + line = " [{}{}]{}".format("█" * filled, "░" * (width - filled), info) + else: + line = " 已下载 {:.1f}MB {}".format(done / 1048576.0, DownloadUtils.format_speed(speed)) + sys.stdout.write("\r" + line + " ") + sys.stdout.flush() + + @staticmethod + def download_from(url, path, give_up_if_slow): + """ + 下载到 path, 返回 (是否下完, 平均速度 B/s). give_up_if_slow 时, 下了 slow_check_seconds 秒平均速度还太慢就放弃. + 不走环境变量里的代理(和 wget --no-proxy 一致), 超时 15 秒 + """ + import collections + import urllib.request + + speed = 0 + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(url, headers={"User-Agent": "Wget/1.21"}) + # 在终端里每 0.2 秒刷新一次; 输出被重定向到文件(日志、自动化测试)时 5 秒一次, 免得刷屏 + interval = 0.2 if sys.stdout.isatty() else 5 + try: + with opener.open(request, timeout=15) as resp, open(path, "wb") as f: + total = int(resp.headers.get("Content-Length") or 0) + done, start, last_print = 0, time.time(), 0 + recent = collections.deque([(start, 0)]) # 最近约 2 秒的 (时间, 已下载), 算实时速度 + while True: + chunk = resp.read(64 * 1024) + if not chunk: + break + f.write(chunk) + done += len(chunk) + now = time.time() + used = now - start + speed = done / max(used, 0.001) # 平均速度, 用来判断这个源快不快 + recent.append((now, done)) + while len(recent) > 2 and now - recent[0][0] > 2: + recent.popleft() + if now - last_print >= interval: + last_print = now + span = now - recent[0][0] + DownloadUtils.draw_progress(done, total, (done - recent[0][1]) / span if span > 0 else speed) + if give_up_if_slow and used > DownloadUtils.slow_check_seconds and speed < DownloadUtils.slow_speed: + print("") + PrintUtils.print_warn("速度较慢({}), 先试试下一个下载源".format(DownloadUtils.format_speed(speed))) + return False, speed + DownloadUtils.draw_progress(done, total, speed) + print("") + return (total == 0 or done == total), speed + except Exception as e: + print("") + PrintUtils.print_warn("下载失败: {}".format(e)) + return False, 0 + + @staticmethod + def download_deb(sources, path): + """sources 是 [(名称, 地址), ...], 按顺序尝试, 下到完整的 deb 就返回 True""" + def deb_ok(): + return CmdTask('dpkg-deb -f "{}" Package'.format(path)).run()[0] == 0 + + slow_sources = [] + for source_name, url in sources: + PrintUtils.print_info("从 {} 下载: {}".format(source_name, url)) + ok, speed = DownloadUtils.download_from(url, path, give_up_if_slow=True) + if ok and deb_ok(): + return True + if speed > 0: + slow_sources.append((speed, source_name, url)) + # 没有一个源够快: 用测下来最快的那个下完 + for speed, source_name, url in sorted(slow_sources, reverse=True): + PrintUtils.print_warn("几个下载源都比较慢, 改用其中最快的 {}({}) 下完, 请耐心等待".format( + source_name, DownloadUtils.format_speed(speed))) + ok, _ = DownloadUtils.download_from(url, path, give_up_if_slow=False) + if ok and deb_ok(): + return True + return False + + class AptUtils: @staticmethod def checkapt(): @@ -1453,6 +1701,24 @@ def checkapt(): return False return True + @staticmethod + def wait_and_repair(): + """ + 安装前的准备, 处理两种新手最常见的 apt 报错: + 1. 刚开机时系统自动更新占着 apt 锁: Could not get lock /var/lib/dpkg/lock-frontend + 2. 上次安装中途 Ctrl+C 留下没配置完的包: dpkg was interrupted, you must manually run 'dpkg --configure -a' + """ + locks = "/var/lib/dpkg/lock-frontend /var/lib/dpkg/lock /var/lib/apt/lists/lock /var/cache/apt/archives/lock" + for i in range(60): + if CmdTask("fuser {} > /dev/null 2>&1".format(locks), 5).run()[0] != 0: + break + if i == 0: + PrintUtils.print_warn( + tr.tr("检测到其他程序(通常是系统自动更新)正在使用软件包管理器, 等待它结束(最多5分钟)...") + ) + time.sleep(5) + CmdTask("sudo dpkg --configure -a", 0).run() + @staticmethod def getArch(): result = CmdTask("dpkg --print-architecture", 2).run() @@ -1563,10 +1829,12 @@ def get_fast_url(urls, timeout=1.5): try: # 记录开始时间 start_time = time.time() - # 创建连接并发送请求 - conn = http.client.HTTPSConnection( - host, timeout=timeout - ) # 使用 HTTPS 连接 + # 按地址本身的协议连接: 原来一律走 https, http 地址测的其实是 https 的延迟, + # 设备时钟不准时 https 证书校验失败, 所有地址都会被判成超时 + if parsed_url.scheme == "http": + conn = http.client.HTTPConnection(host, timeout=timeout) + else: + conn = http.client.HTTPSConnection(host, timeout=timeout) conn.request("GET", path) response = conn.getresponse() # 记录结束时间 @@ -1590,6 +1858,192 @@ def get_fast_url(urls, timeout=1.5): return sorted_urls +class AdUtils: + """ + 一键安装里的推广信息. 内容从官网接口 /api/install/ads 读取(官网后台「一键安装」页可改商品链接、QQ 群等), + 读不到时用下面的内置内容. 按场景展示, 每个场景一次运行只展示一次; 安装失败后不再展示商品推广. + 接口返回格式: {"scenes": {"场景名": {"title": 标题, "lines": [...], "lines_en": [...], "shop": true}}} + 10-28 导师定不放二维码(终端里画出来太大), 只放链接, 链接用淡蓝色加下划线, 和前后文字区分开 + 测试时可用环境变量 FISHROS_API_URL 指向别的官网地址, 默认 http://fishros.com/api + """ + + shop_url = "https://item.taobao.com/item.htm?abbucket=0&id=1083795293714" + mp_line = "关注微信公众号「鱼香ROS」, 获取 ROS 教程、答疑和新品消息" + default_scenes = { + # 刚运行一键安装、菜单出现之前, 只放文字 + "welcome": { + "shop": True, + "lines": ["鱼香ROS小铺: 能建图、会导航的 ROS 机器人, 淘宝搜店「鱼香ROS」或打开: " + shop_url, mp_line], + }, + # 开始下载安装 ROS, 要等好几分钟 + "installing": { + "shop": True, + "lines": ["安装需要几分钟, 等待的时候可以逛逛鱼香ROS小铺: 淘宝搜店「鱼香ROS」"], + }, + # ROS 装好了 + "ros_installed": { + "shop": True, + "title": "鱼香ROS小铺", + "lines": [ + "ROS 装好了, 想让它跑在真机器人上? 小铺有能建图、会导航的 ROS 机器人", + "淘宝搜店「鱼香ROS」或打开: " + shop_url, + mp_line, + ], + }, + # 导航/建图等常用功能包装好了 + "pkgs_installed": { + "shop": True, + "title": "鱼香ROS小铺", + "lines": [ + "导航、建图功能包已就绪, 配一台能建图会导航的机器人就能上手实战", + "淘宝搜店「鱼香ROS」或打开: " + shop_url, + mp_line, + ], + }, + # 程序结束, 本次没展示过商品推广时补一条 + "finish_shop": { + "shop": True, + "lines": ["鱼香ROS小铺: ROS 学习用的移动机器人, 淘宝搜店「鱼香ROS」或打开: " + shop_url], + }, + # 程序结束, 不论成败都展示 + "finish": { + "lines": [ + "欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)", + mp_line, + "如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈", + ], + "lines_en": [ + "Welcome to join the robot learning exchange QQ group (In Chinese): 438144612", + "If you encounter any problems, please visit https://fishros.org.cn/forum for feedback", + ], + }, + } + scenes = None + # 官网后台可配置的链接, 如 proxy_subscribe(代理工具推荐的订阅注册链接); 接口读不到时为空, 由调用方用默认值 + links = {} + shown_scenes = set() + failed = False + + @staticmethod + def api_url(path): + return os.environ.get("FISHROS_API_URL", "http://fishros.com/api").rstrip("/") + path + + @classmethod + def load(cls): + if cls.scenes is not None: + return cls.scenes + cls.scenes = dict(cls.default_scenes) + try: + import gzip + import json + import urllib.request + + # 二维码的颜色转义很占地方, 压缩后从约 48KB 变成 2KB 不到, 网差时 3 秒内也能读完 + req = urllib.request.Request(cls.api_url("/install/ads"), headers={"Accept-Encoding": "gzip"}) + with urllib.request.urlopen(req, timeout=3) as resp: + data = resp.read() + if resp.headers.get("Content-Encoding") == "gzip": + data = gzip.decompress(data) + data = json.loads(data.decode("utf-8")) + cls.scenes.update(data.get("scenes", {})) + cls.links = data.get("links") or {} + except Exception: + pass + return cls.scenes + + @classmethod + def link(cls, name, default): + """取官网后台配置的链接; 后台没配或接口读不到用 default, 后台特意留空则返回空字符串""" + cls.load() + value = cls.links.get(name) + return default if value is None else value + + @classmethod + def mark_failed(cls): + cls.failed = True + + @classmethod + def show(cls, scene): + if scene in cls.shown_scenes: + return + ad = cls.load().get(scene) + if not ad: + return + if ad.get("shop"): + if cls.failed: + return + if scene == "finish_shop" and any(cls.load().get(s, {}).get("shop") for s in cls.shown_scenes): + return + # 推广内容是中文的, 英文界面只展示提供了英文的场景 + chinese = tr is None or getattr(tr, "lang", "zh_CN") == "zh_CN" + lines = ad.get("lines", []) if chinese else ad.get("lines_en", []) + if not lines: + return + cls.shown_scenes.add(scene) + if ad.get("title"): + PrintUtils.print_success("-" * 20 + " {} ".format(ad["title"]) + "-" * 20) + for line in lines: + PrintUtils.print_success(cls.color_links(line)) + + @staticmethod + def color_links(line): + # 链接改成淡蓝色(256 色里的 117)加下划线, 链接后面的文字恢复成 print_success 的绿色 + return re.sub(r"(https?://[^\s,。]+)", "\033[4;38;5;117m\\1\033[0;32m", line) + + +class StatUtils: + """ + 官网按天匿名计数: 每个事件只发一个 /install/stat?e=<事件> 请求, 服务器只记 日期+事件 的次数. + 事件: run(启动) / menu:<编号>(主菜单选择) / ros::(菜单 [1] 安装 ROS). + 放后台线程, 3 秒超时, 失败就忽略, 不拖慢也不打断安装; + GitHub Actions 和用配置文件(FISH_INSTALL_CONFIG)自动跑的测试不计入 + """ + + threads = [] + + @staticmethod + def enabled(): + return os.environ.get("GITHUB_ACTIONS") != "true" and os.environ.get("FISH_INSTALL_CONFIG") is None + + @classmethod + def report(cls, event): + if not cls.enabled(): + return + + def send(): + try: + import urllib.request + + urllib.request.urlopen(AdUtils.api_url("/install/stat?e=" + event), timeout=3).close() + except Exception: + pass + + thread = threading.Thread(target=send) + thread.daemon = True + thread.start() + cls.threads.append(thread) + + @classmethod + def flush(cls, timeout=2): + """程序结束前最多再等 timeout 秒, 让最后才发的(装成功/失败)统计发出去; 平时早就发完了, 不用等""" + deadline = time.time() + timeout + for thread in cls.threads: + thread.join(max(0, deadline - time.time())) + + @staticmethod + def system(): + """失败按系统代号(jammy、focal 等)分开计数, 取不到时记 unknown""" + try: + codename = re.sub("[^a-z0-9]", "", str(osversion.get_codename()).lower())[:20] + except Exception: + codename = "" + return codename or "unknown" + + @classmethod + def report_ros_result(cls, mode, ok): + cls.report("ros:{}:ok".format(mode) if ok else "ros:{}:fail:{}".format(mode, cls.system())) + + """ 定义基础任务 """ @@ -1599,6 +2053,8 @@ class BaseTool: TYPE_INSTALL = 0 TYPE_UNINSTALL = 1 TYPE_CONFIG = 2 + # run() 或流程中的某一步返回它, 表示用户选了 [0] 返回上一步 + BACK = "back" def __init__(self, name, tool_type): self.osinfo = osversion @@ -1633,7 +2089,10 @@ def run_tool_file(file, authorun=True): return tool if tool.init() == False: return False - if tool.run() == False: + result = tool.run() + if result == BaseTool.BACK: + return BaseTool.BACK + if result == False: return False if tool.uninit() == False: return False @@ -1642,7 +2101,7 @@ def run_tool_file(file, authorun=True): def run_tool_url(url, url_prefix): CmdTask( - "wget {} -O /tmp/fishinstall/tools/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/tools/{} -T 15 -t 3 --no-check-certificate".format( url, url[url.rfind("/") + 1 :] ) ).run() @@ -1654,7 +2113,7 @@ def download_tools(id, tools, url_prefix): url = tools[id]["tool"] url = os.path.join(url_prefix, url) CmdTask( - "wget {} -O /tmp/fishinstall/tools/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/tools/{} -T 15 -t 3 --no-check-certificate".format( url, url[url.rfind("/") + 1 :] ) ).run() @@ -1663,7 +2122,7 @@ def download_tools(id, tools, url_prefix): url = tools[dep]["tool"] url = os.path.join(url_prefix, url) CmdTask( - "wget {} -O /tmp/fishinstall/tools/{} --no-check-certificate".format( + "wget {} -O /tmp/fishinstall/tools/{} -T 15 -t 3 --no-check-certificate".format( url, url[url.rfind("/") + 1 :] ) ).run() diff --git a/tools/keys/ros.asc b/tools/keys/ros.asc new file mode 100644 index 0000000..29454bc --- /dev/null +++ b/tools/keys/ros.asc @@ -0,0 +1,42 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1 + +mQINBFzvJpYBEADY8l1YvO7iYW5gUESyzsTGnMvVUmlV3XarBaJz9bGRmgPXh7jc +VFrQhE0L/HV7LOfoLI9H2GWYyHBqN5ERBlcA8XxG3ZvX7t9nAZPQT2Xxe3GT3tro +u5oCR+SyHN9xPnUwDuqUSvJ2eqMYb9B/Hph3OmtjG30jSNq9kOF5bBTk1hOTGPH4 +K/AY0jzT6OpHfXU6ytlFsI47ZKsnTUhipGsKucQ1CXlyirndZ3V3k70YaooZ55rG +aIoAWlx2H0J7sAHmqS29N9jV9mo135d+d+TdLBXI0PXtiHzE9IPaX+ctdSUrPnp+ +TwR99lxglpIG6hLuvOMAaxiqFBB/Jf3XJ8OBakfS6nHrWH2WqQxRbiITl0irkQoz +pwNEF2Bv0+Jvs1UFEdVGz5a8xexQHst/RmKrtHLct3iOCvBNqoAQRbvWvBhPjO/p +V5cYeUljZ5wpHyFkaEViClaVWqa6PIsyLqmyjsruPCWlURLsQoQxABcL8bwxX7UT +hM6CtH6tGlYZ85RIzRifIm2oudzV5l+8oRgFr9yVcwyOFT6JCioqkwldW52P1pk/ +/SnuexC6LYqqDuHUs5NnokzzpfS6QaWfTY5P5tz4KHJfsjDIktly3mKVfY0fSPVV +okdGpcUzvz2hq1fqjxB6MlB/1vtk0bImfcsoxBmF7H+4E9ZN1sX/tSb0KQARAQAB +tCZPcGVuIFJvYm90aWNzIDxpbmZvQG9zcmZvdW5kYXRpb24ub3JnPokCVAQTAQgA +PgIbAwULCQgHAgYVCgkICwIEFgIDAQIeAQIXgBYhBMHPbjHmut6IaLFytPQu1vur +F8ZUBQJgsdhRBQkLTMW7AAoJEPQu1vurF8ZUTMwP/3f7EkOPIFjUdRmpNJ2db4iB +RQu5b2SJRG+KIdbvQBzKUBMV6/RUhEDPjhXZI3zDevzBewvAMKkqs2Q1cWo9WV7Z +PyTkvSyey/Tjn+PozcdvzkvrEjDMftIk8E1WzLGq7vnPLZ1q/b6Vq4H373Z+EDWa +DaDwW72CbCBLWAVtqff80CwlI2x8fYHKr3VBUnwcXNHR4+nRABfAWnaU4k+oTshC +Qucsd8vitNfsSXrKuKyz91IRHRPnJjx8UvGU4tRGfrHkw1505EZvgP02vXeRyWBR +fKiL1vGy4tCSRDdZO3ms2J2m08VPv65HsHaWYMnO+rNJmMZj9d9JdL/9GRf5F6U0 +quoIFL39BhUEvBynuqlrqistnyOhw8W/IQy/ymNzBMcMz6rcMjMwhkgm/LNXoSD1 +1OrJu4ktQwRhwvGVarnB8ihwjsTxZFylaLmFSfaA+OAlOqCLS1OkIVMzjW+Ul6A6 +qjiCEUOsnlf4CGlhzNMZOx3low6ixzEqKOcfECpeIj80a2fBDmWkcAAjlHu6VBhA +TUDG9e2xKLzV2Z/DLYsb3+n9QW7KO0yZKfiuUo6AYboAioQKn5jh3iRvjGh2Ujpo +22G+oae3PcCc7G+z12j6xIY709FQuA49dA2YpzMda0/OX4LP56STEveDRrO+CnV6 +WE+F5FaIKwb72PL4rLi4iQJUBBMBCAA+AhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4B +AheAFiEEwc9uMea63ohosXK09C7W+6sXxlQFAmgSGgYFCRS0dnAACgkQ9C7W+6sX +xlS/UA//aAgP67DunDdak96+fLemWJkl4PHhj6637lzacJ+SlRzeUbnS/2XLhmk1 +BNYoib3IHp3GBqvLsQqkCUZWaJTvkkAvJ+1W2N7JByt7Z/tnTS7aVfDxF53nYCxY +eSH921y2AtIZCIl1N3R2ic7pyzNkVVqwKIV1EqWLMa8GQTy4V0pgwaLE6Ce9Bmtv +04upGyiPXRoPM3Rfc0mTUtPGJLf651img6TYGb1UbKs2aAitiI2ptg8EdiRYYcGo +nG8Ar3aUnYj+fpfhTyvqwx0MTtAPDiMUx2vELReYIvhwU+SRHWpp20nL0WIK2krK +qIq5SwIboBSLkQ5j7tjehKkqfxanUrlUxu/XYlEhq0Mh5oCfBrarIFBUBULUX86p +ZQUqW4+MrIxHcNcrCPGm3U/4dSZ1rTAdyeEUi7a2H96CYYofl7dq1xXGMDFh+b5/ +3Yw3t8US4VCwxmEj+C3ciARJauB1oDOilEieszPvIS3PdVpp6HCZRRHaB689AzMF +FoD40iowsNS9XmO6O8V7xzVVS0EtNhz9qUGIz8yjWeLLdpR8NqHOFOvrPP66voEV +Gc0Va/nozc05WWt42bc0hs1faRMqHRlAlJIKSUm4NSqc+YDNPYFlZSnB97tBhHC9 +CEXRgHY3Utq/I3CLJ+KcJCUCH5D16Z7aOoazG9DKbewA+da8Drw= +=9IZg +-----END PGP PUBLIC KEY BLOCK----- diff --git a/tools/tool_config_docker_proxy.py b/tools/tool_config_docker_proxy.py index e619516..d0497f8 100644 --- a/tools/tool_config_docker_proxy.py +++ b/tools/tool_config_docker_proxy.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask,DockerUtils from .base import osversion,osarch from .base import run_tool_file @@ -18,7 +18,8 @@ def config_docker_proxy(self): dic = {1:"VPN模式(需要本地有支持HTTP代理)",2:"服务模式(需要提供支持Docker代理的服务器地址)",3:"删除所有模式代理"} code,result = ChooseTask(dic, "请选择代理模式,如果不清楚可以选2").run() - # 根据系统架构下载不同版本的安装包 + if code==0: + return BaseTool.BACK if code==1: url = input('请输入代理所在地址(直接回车使用:127.0.0.1):') if not url: @@ -33,17 +34,12 @@ def config_docker_proxy(self): CmdTask("sudo systemctl daemon-reload").run() CmdTask("sudo systemctl restart docker").run() elif code==2: - url = input('请输入代理网址(直接回车将进入选择界面):') + # 原来的 dockerpull.com / docker.fxxk.dedyn.io 已失效, 只保留华为云加速, 回车直接用它 + url = input('请输入代理网址(直接回车使用华为云加速):').strip() if not url: - dic = {1:"https://dockerpull.com",2:"https://docker.fxxk.dedyn.io",3:"https://ac77d18f43e44819b553040f294b8f9b.mirror.swr.myhuaweicloud.com"} - code,result = ChooseTask(dic, "请选择代理加速网址").run() - url = dic[code] - print(url) - proxy_config_vpn = '{{"registry-mirrors": ["{}"]}}'.format(url) - FileUtils.new('/etc/docker/','daemon.json',proxy_config_vpn) - PrintUtils.print_info("在{}/{}写入了{},接下来为你重启Docker!".format('/etc/docker/', 'daemon.json', proxy_config_vpn)) - CmdTask("sudo systemctl daemon-reload").run() - CmdTask("sudo systemctl restart docker").run() + url = DockerUtils.registry_mirror + PrintUtils.print_info("在{}写入加速地址{}, 接下来为你重启Docker!".format(DockerUtils.daemon_json, url)) + DockerUtils.set_registry_mirror(url) elif code==3: CmdTask("sudo rm /etc/systemd/system/docker.service.d/proxy.conf").run() CmdTask("sudo rm /etc/docker/daemon.json").run() @@ -52,5 +48,5 @@ def config_docker_proxy(self): PrintUtils.print_info("配置完成,接下来你可以尝试再次拉取镜像了~") def run(self): - self.config_docker_proxy() + return self.config_docker_proxy() diff --git a/tools/tool_config_rosenv.py b/tools/tool_config_rosenv.py index 4e4e1ff..4de1cf7 100644 --- a/tools/tool_config_rosenv.py +++ b/tools/tool_config_rosenv.py @@ -48,5 +48,26 @@ def get_source_command(dic): FileUtils.append(shell_path,"# >>> fishros initialize >>>\n"+data+"\n# <<< fishros initialize <<<\n") # 添加 PrintUtils.print_text("") + def fix_rosidl_non_ascii_path(self): + """ + ROS2 的 rosidl_adapter 用 CMake 的 file(STRINGS) 读回接口列表时没指定 UTF-8, CMake 会把中文等非 ASCII + 字符当作分隔符丢掉: 工作空间放在 ~/桌面 这类中文路径下, colcon build 编译消息包就报 + "rosidl_generate_interfaces ... list index: 1 out of range" / "Target dependency ... .idl does not exist". + 上游 humble/jazzy/rolling 都没修, 这里给已装的 ROS2 补上 ENCODING UTF-8; ROS 升级覆盖后再运行一次本工具即可 + """ + import glob + old = 'idl_output}" idl_tuples)' + for path in glob.glob("/opt/ros/*/share/rosidl_adapter/cmake/rosidl_adapt_interfaces.cmake"): + with open(path) as f: + if old not in f.read(): + continue + CmdTask( + "sudo sed -i.fishros-bak 's#{}#idl_output}}\" idl_tuples ENCODING UTF-8)#' {}".format(old, path), 10 + ).run() + with open(path) as f: + if "idl_tuples ENCODING UTF-8)" in f.read(): + PrintUtils.print_success("已修复 {} 在中文路径下编译消息包报错的问题".format(path.split("/")[3])) + def run(self): self.config_rosenv() + self.fix_rosidl_non_ascii_path() diff --git a/tools/tool_config_system_source.py b/tools/tool_config_system_source.py index ac1bc5e..0fadabe 100644 --- a/tools/tool_config_system_source.py +++ b/tools/tool_config_system_source.py @@ -1,8 +1,14 @@ # -*- coding: utf-8 -*- +import os +import re +import time +import urllib.error +import urllib.request from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask -from .base import osversion +from .base import osversion, tr from .base import run_tool_file +from urllib.parse import urlparse @@ -18,12 +24,27 @@ deb / -backports main restricted universe multiverse deb / -security main restricted universe multiverse """ -debian_amd64_sources_template = """ +# Debian 10 及更早: 安全源路径是 /updates +debian_old_sources_template = """ deb / main contrib non-free deb / -updates main contrib non-free deb / -backports main contrib non-free deb -security /updates main contrib non-free """ +# Debian 11 起: 安全源改为 -security +debian_amd64_sources_template = """ +deb / main contrib non-free +deb / -updates main contrib non-free +deb / -backports main contrib non-free +deb -security/ -security main contrib non-free +""" +# Debian 12 起: 固件单独拆到 non-free-firmware +debian_firmware_sources_template = """ +deb / main contrib non-free non-free-firmware +deb / -updates main contrib non-free non-free-firmware +deb / -backports main contrib non-free non-free-firmware +deb -security/ -security main contrib non-free non-free-firmware +""" ubuntu_ports_deb822_template = """ Types: deb URIs: / @@ -52,196 +73,324 @@ Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg """ +# Ubuntu 24.04 起默认使用 deb822 格式的 ubuntu.sources, 不再用 sources.list +ubuntu_deb822_file = '/etc/apt/sources.list.d/ubuntu.sources' + +# 系统镜像只用 http: 老系统证书过期、设备时钟不准时 https 会失败(与一键安装其它下载一致). +# 2026-10 实测: kernel.org 在国内只有一百多 KB/s, 网易 163 连不上, 阿里云 Ubuntu 带宽很低, 都不放进来 +ubuntu_mirrors = [ + ("清华大学", "http://mirrors.tuna.tsinghua.edu.cn/ubuntu"), + ("中国科学技术大学", "http://mirrors.ustc.edu.cn/ubuntu"), + ("中山大学", "http://mirror.sysu.edu.cn/ubuntu"), + ("北京外国语大学", "http://mirrors.bfsu.edu.cn/ubuntu"), + ("南京大学", "http://mirror.nju.edu.cn/ubuntu"), + ("华为云", "http://repo.huaweicloud.com/ubuntu"), + ("Ubuntu官方", "http://archive.ubuntu.com/ubuntu"), +] +# arm64/armhf(树莓派、Jetson 等)用 ubuntu-ports +ubuntu_ports_mirrors = [ + ("清华大学", "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports"), + ("中国科学技术大学", "http://mirrors.ustc.edu.cn/ubuntu-ports"), + ("中山大学", "http://mirror.sysu.edu.cn/ubuntu-ports"), + ("南京大学", "http://mirror.nju.edu.cn/ubuntu-ports"), + ("华为云", "http://repo.huaweicloud.com/ubuntu-ports"), + ("Ubuntu官方", "http://ports.ubuntu.com/ubuntu-ports"), +] +# 已停止支持的 Ubuntu 版本(如 24.10)会从普通镜像删除, 只能用旧版本归档源, 所有架构都在里面 +ubuntu_old_mirrors = [ + ("中国科学技术大学(旧版本归档)", "http://mirrors.ustc.edu.cn/ubuntu-old-releases/ubuntu"), + ("南京大学(旧版本归档)", "http://mirror.nju.edu.cn/ubuntu-old-releases/ubuntu"), + ("Ubuntu官方(旧版本归档)", "http://old-releases.ubuntu.com/ubuntu"), +] +# Debian 的镜像不区分架构, arm64/armhf 和 amd64 用同一套地址 +debian_mirrors = [ + ("清华大学", "http://mirrors.tuna.tsinghua.edu.cn/debian"), + ("中国科学技术大学", "http://mirrors.ustc.edu.cn/debian"), + ("中山大学", "http://mirror.sysu.edu.cn/debian"), + ("阿里云", "http://mirrors.aliyun.com/debian"), + ("南京大学", "http://mirror.nju.edu.cn/debian"), + ("华为云", "http://repo.huaweicloud.com/debian"), + ("Debian官方", "http://deb.debian.org/debian"), +] + +# 判断当前系统源是国内还是国外: 上面列表里的都认识, 另外补几个常见的; 其余以 .cn 结尾的域名也算国内 +domestic_hosts = { + "cn.archive.ubuntu.com": "Ubuntu官方中国镜像", + "mirrors.aliyun.com": "阿里云", + "mirrors.cloud.tencent.com": "腾讯云", + "mirrors.tencent.com": "腾讯云", + # 云服务器自带的内网镜像 + "mirrors.tencentyun.com": "腾讯云内网镜像", + "mirrors.cloud.aliyuncs.com": "阿里云内网镜像", + "mirrors.163.com": "网易", +} +foreign_hosts = { + "archive.ubuntu.com": "Ubuntu官方", + "security.ubuntu.com": "Ubuntu官方", + "ports.ubuntu.com": "Ubuntu官方", + "old-releases.ubuntu.com": "Ubuntu官方", + "deb.debian.org": "Debian官方", + "security.debian.org": "Debian官方", + "mirrors.kernel.org": "kernel.org", +} + class Tool(BaseTool): def __init__(self): self.type = BaseTool.TYPE_CONFIG self.name = "一键更换系统源" self.author = '小鱼' + self.use_deb822 = False + self.cancelled = False + self.backups = [] + self.ranking = {} + self.source_names = {} def add_ros_source(self): - """快速添加ROS源""" - dic = {1:"添加ROS/ROS2源",2:"不添加ROS/ROS2源"} - code,result = ChooseTask(dic, "请问是否添加ROS和ROS2源?").run() - if code==2: return - tool = run_tool_file('tools.tool_install_ros',authorun=False) - if not tool.support_install(): return False - tool.add_key() - tool.add_source() - - def clean_old_source(self): - dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} - code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() - FileUtils.delete('/etc/apt/sources.list') - if code==2: - print("删除一个资源文件") - FileUtils.delete('/etc/apt/sources.list.d') - # fix add source failed before config system source + """快速添加ROS源. 选 [0] 返回 BaseTool.BACK""" + while True: + dic = {1:"添加ROS/ROS2源",2:"不添加ROS/ROS2源"} + code,result = ChooseTask(dic, "请问是否添加ROS和ROS2源?").run() + if code==0: return BaseTool.BACK + if code!=1: return + tool = run_tool_file('tools.tool_install_ros',authorun=False) + if not tool.support_install(): return False + tool.add_key() + # 在选择ROS镜像那一步返回上一步, 回到这个问题 + if tool.add_source() == BaseTool.BACK: continue + return + + def backup_path(self, path): + """把要清理的源文件/目录改名备份, 而不是直接删除, 出问题可以手动恢复""" + if not os.path.exists(path): + return None + backup = '{}.fishros-bak-{}'.format(path, time.strftime('%Y%m%d%H%M%S')) + CmdTask('sudo mv {} {}'.format(path, backup)).run() + self.backups.append((backup, path)) + return backup + + def backup_third_party_sources(self): + """ + 把 sources.list.d 里的第三方源(除系统自己的 ubuntu.sources 外)挪到备份目录. + 返回备份目录, 没有第三方源时返回 None + """ + src_dir = '/etc/apt/sources.list.d' + if not os.path.isdir(src_dir): + return None + files = [f for f in os.listdir(src_dir) if f != 'ubuntu.sources'] + if not files: + return None + backup = '{}.fishros-bak-{}'.format(src_dir, time.strftime('%Y%m%d%H%M%S')) + CmdTask('sudo mkdir -p {}'.format(backup)).run() + for f in files: + CmdTask('sudo mv {}/{} {}/'.format(src_dir, f, backup)).run() + self.backups.append((backup, src_dir)) + return backup + + def restore_sources(self): + """用户中途选了退出: 把这次挪走、改掉的源文件恢复回去, 系统源保持换源前的样子""" + for backup, original in reversed(self.backups): + if original == '/etc/apt/sources.list.d': + CmdTask('sudo sh -c "mv {0}/* {1}/ && rmdir {0}"'.format(backup, original)).run() + else: + CmdTask('sudo mv {} {}'.format(backup, original)).run() + self.backups = [] + if getattr(self, 'original_deb822', None) is not None: + FileUtils.new('/etc/apt/sources.list.d/', 'ubuntu.sources', self.original_deb822) + PrintUtils.print_warn('已撤销本次换源, 系统源恢复成原来的配置') + + def ask_source_options(self, step=0): + """高手模式换源前的两个问题, 选 [0] 退回前一个问题; 在第一个问题退回时返回 BaseTool.BACK""" + while True: + if step == 0: + dic = {1:"仅更换系统源",2:"更换系统源并清理第三方源"} + self.clean_code,result = ChooseTask(dic, "请选择换源方式,如果不知道选什么请选2").run() + if self.clean_code == 0: + return BaseTool.BACK + step = 1 + continue + PrintUtils.print_info("源选择方式说明:") + PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") + PrintUtils.print_info("2. 根据测速结果手动选择源: 系统将测试各个源的速度,然后让您从测试结果中选择") + dic_source_method = {1:"自动测速选择最快的源", 2:"根据测速结果手动选择源"} + self.source_method_code, _ = ChooseTask(dic_source_method, "请选择源的选择方式").run() + if self.source_method_code == 0: + step = 0 + continue + return True + + def clean_old_source(self, auto=False): + """备份旧的源配置: 改名备份而不是删除, 用户中途返回上一步时可以原样恢复""" + if auto: + # 全自动模式: 只换系统源, 第三方源先保留, apt update 失败时再挪走(见 apply_source) + code = 1 + self.source_method_code = 1 + else: + code = self.clean_code + self.backups = [] + # 清理前先记下系统是否使用 deb822 格式(24.04+), 否则清理三方源时会连 ubuntu.sources 一起挪走, 就判断不出来了 + self.use_deb822 = os.path.exists(ubuntu_deb822_file) + self.original_deb822 = None + if self.use_deb822: + with open(ubuntu_deb822_file, encoding='utf-8') as f: + self.original_deb822 = f.read() + backup = '/etc/apt/ubuntu.sources.fishros.bak' + if not os.path.exists(backup): + CmdTask('sudo cp {} {}'.format(ubuntu_deb822_file, backup)).run() + self.backup_path('/etc/apt/sources.list') + if code==2: + backup_dir = self.backup_third_party_sources() + if backup_dir: + PrintUtils.print_info('第三方源已备份到 {}, 如需恢复可把里面的文件挪回 /etc/apt/sources.list.d/'.format(backup_dir)) + # fix add source failed before config system source CmdTask('sudo mkdir -p /etc/apt/sources.list.d').run() - - # 添加选择源的方式 - PrintUtils.print_info("源选择方式说明:") - PrintUtils.print_info("1. 自动测速选择最快的源: 系统将自动测试各个源的速度,并选择最快的源") - PrintUtils.print_info("2. 根据测速结果手动选择源: 系统将测试各个源的速度,然后让您从测试结果中选择") - - dic_source_method = {1:"自动测速选择最快的源", 2:"根据测速结果手动选择源"} - self.source_method_code, _ = ChooseTask(dic_source_method, "请选择源的选择方式").run() + + def detect_current_source(self): + """ + 读出当前系统源的第一个地址, 判断是国内源还是国外源. + 返回 (地址, 名称, 'cn'/'foreign'/'unknown'), 读不到地址时地址为 None + """ + url = None + for path in (ubuntu_deb822_file, '/etc/apt/sources.list.d/debian.sources', '/etc/apt/sources.list'): + try: + with open(path, encoding='utf-8', errors='ignore') as f: + lines = f.read().splitlines() + except Exception: + continue + for line in lines: + m = re.match(r'\s*(?:URIs:\s*|deb\s+(?:\[[^\]]*\]\s+)?)(\S+)', line) + if m: + url = m.group(1) + break + if url: + break + if not url: + return None, '', 'unknown' + host = urlparse(url).netloc.lower() + names = {} + for name, mirror in ubuntu_mirrors + ubuntu_ports_mirrors + ubuntu_old_mirrors + debian_mirrors: + names[urlparse(mirror).netloc] = name + if host in domestic_hosts: + return url, domestic_hosts[host], 'cn' + if host in foreign_hosts or host.endswith('.archive.ubuntu.com'): + return url, foreign_hosts.get(host, 'Ubuntu官方'), 'foreign' + if host in names or host.endswith('.cn'): + return url, names.get(host, ''), 'cn' + return url, '', 'unknown' + + def ask_change_before_install(self): + """ + 装 ROS 前问要不要换系统源(高手模式第一步、菜单 16 都用它): 先检测当前系统源, 国外源建议换, 国内源建议不换. + 选 [0] 返回 BaseTool.BACK + """ + url, name, region = self.detect_current_source() + source = '{} {}'.format(name, url).strip() if url else '没有找到' + if tr is not None and hasattr(tr, 'isCN') and not tr.isCN(): + PrintUtils.print_info('检测到当前系统源: {}'.format(source)) + tips = '您不在中国大陆, 一般不用换, 建议选2' + elif region == 'cn': + PrintUtils.print_success('检测到当前系统源是国内源: {}'.format(source)) + tips = '当前已经是国内源, 一般不用换, 建议选2; 如果之前安装失败过或下载很慢, 可以选1重新测速换源' + elif region == 'foreign': + PrintUtils.print_warn('检测到当前系统源是国外源: {}'.format(source)) + tips = '国外源在国内下载慢、容易失败, 建议选1换成国内源' + else: + PrintUtils.print_warn('检测到当前系统源: {}, 无法判断是国内还是国外源'.format(source)) + tips = '如果不确定, 建议选1自动测速换成国内源' + dic = {1:"更换系统源再继续安装",2:"不更换继续安装"} + while True: + code,result = ChooseTask(dic, tips).run() + if code == 0: + return BaseTool.BACK + if code == 2: + return + # 换源时在第一个问题选了返回上一步, 回到这个问题 + if self.change_sys_source() != BaseTool.BACK: + return + + def get_debian_template(self, codename): + if codename in ('wheezy', 'jessie', 'stretch', 'buster'): + return debian_old_sources_template + if codename == 'bullseye': + return debian_amd64_sources_template + return debian_firmware_sources_template + + @staticmethod + def measure_source(url, codename, timeout=4): + """ + 下载镜像里的 dists/<代号>/Release 测速: 测的是真实下载速度(原来只测首包延迟, 带宽很低的镜像也会排第一), + 同时确认镜像里确实有这个系统版本. 返回 (KB/s, 状态), 状态: ok / missing(镜像里没有) / fail(连不上) + """ + # 带上 apt 的 User-Agent: 镜像对 apt 和其它下载工具的策略可能不同(中科大会拒绝 wget) + request = urllib.request.Request( + '{}/dists/{}/Release'.format(url, codename), headers={'User-Agent': 'Debian APT-HTTP/1.3'}) + start = time.time() + size = 0 + try: + with urllib.request.urlopen(request, timeout=timeout) as resp: + while time.time() - start < timeout: + chunk = resp.read(16384) + if not chunk: + break + size += len(chunk) + except urllib.error.HTTPError as e: + return 0, 'missing' if e.code == 404 else 'fail' + except Exception: + return 0, 'fail' + if size == 0: + return 0, 'fail' + return size / 1024.0 / max(time.time() - start, 0.001), 'ok' + + def rank_mirrors(self, mirrors, codename): + """逐个测速, 返回按速度从快到慢排好的地址列表, 以及是否每个连得上的镜像都没有这个系统版本""" + speeds = {} + missing = 0 + for name, url in mirrors: + self.source_names[url] = name + speed, status = self.measure_source(url, codename) + # 镜像偶尔会有一次连接超时(实测每轮都有一两个), 再试一次, 免得把最快的镜像误判掉 + if status == 'fail': + speed, status = self.measure_source(url, codename) + if status == 'ok': + speeds[url] = speed + PrintUtils.print_success('- {}\t{}\t速度: {:.0f}KB/s'.format(name, url, speed)) + elif status == 'missing': + missing += 1 + PrintUtils.print_info('- {}\t{}\t没有 {} 版本'.format(name, url, codename)) + else: + PrintUtils.print_info('- {}\t{}\t连接失败'.format(name, url)) + ranked = sorted(speeds, key=speeds.get, reverse=True) + return ranked, missing > 0 and not ranked def get_source_by_system(self,system,codename,arch,failed_sources=[], return_all=False): - # 实际测试发现,阿里云虽然延时很低,但是带宽也低的离谱,一点都不用心,删掉了 - ubuntu_amd64_sources = [ - "https://mirrors.tuna.tsinghua.edu.cn/ubuntu", - "https://mirror.sysu.edu.cn/ubuntu/", - # "https://mirrors.aliyun.com/ubuntu", - # "https://mirrors.163.com/ubuntu", - "https://mirrors.ustc.edu.cn/ubuntu", - "https://archive.ubuntu.com/ubuntu", - "https://mirrors.kernel.org/ubuntu", - "http://mirrors.tuna.tsinghua.edu.cn/ubuntu", - "http://mirror.sysu.edu.cn/ubuntu/", - # "http://mirrors.aliyun.com/ubuntu", - # "http://mirrors.163.com/ubuntu", - "http://mirrors.ustc.edu.cn/ubuntu", - "http://archive.ubuntu.com/ubuntu", - "http://mirrors.kernel.org/ubuntu", - ] - ubuntu_ports_sources = [ - "https://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", - "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "https://mirror.sysu.edu.cn/ubuntu-ports/", - "https://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", - "https://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "http://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", - "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - "http://mirror.sysu.edu.cn/ubuntu-ports/", - "http://ports.ubuntu.com/ubuntu-ports", - # "https://mirrors.aliyun.com/ubuntu-ports", - "http://mirrors.tuna.tsinghua.edu.cn/ubuntu-ports", - ] - debian_amd64_sources = [ - "https://mirrors.tuna.tsinghua.edu.cn/debian", - "https://mirror.sysu.edu.cn/debian/", - "https://mirrors.aliyun.com/debian", - # "https://mirrors.163.com/debian", - "https://mirrors.ustc.edu.cn/debian", - "https://deb.debian.org/debian", - "https://mirrors.kernel.org/debian", - "http://mirrors.tuna.tsinghua.edu.cn/debian", - "http://mirror.sysu.edu.cn/debian/", - "http://mirrors.aliyun.com/debian", - # "http://mirrors.163.com/debian", - "http://mirrors.ustc.edu.cn/debian", - "http://deb.debian.org/debian", - "http://mirrors.kernel.org/debian", - ] - sources = [] - template = ubuntu_amd64_sources_template - if system=='ubuntu': - if arch=='amd64': - sources = ubuntu_amd64_sources - template = ubuntu_amd64_sources_template + if system == 'ubuntu': + mirrors = ubuntu_mirrors if arch == 'amd64' else ubuntu_ports_mirrors + if arch == 'amd64': + template = ubuntu_amd64_deb822_template if self.use_deb822 else ubuntu_amd64_sources_template else: - sources = ubuntu_ports_sources - template = ubuntu_ports_sources_template - elif system=='debian': - if arch=='amd64': - template = debian_amd64_sources_template - sources = debian_amd64_sources - PrintUtils.print_delay('搜索到可用源:{}'.format(sources),0.002) - PrintUtils.print_delay('接下来将进行自动测速以为您选择最快的源:') - fast_source = AptUtils.get_fast_url(sources) - - # 如果需要返回所有源和模板(不进行测速) - if return_all: - # 直接返回源列表和模板 - if len(failed_sources) > 0: - filtered_sources = [source for source in sources if source not in failed_sources] - return filtered_sources, template - return sources, template - - # 正常返回最快的源 - if len(failed_sources)>0: - PrintUtils.print_warn('接下来为您排除已经失败的源') - for source in fast_source: - if source in failed_sources: - PrintUtils.print_info('{} 已经测试失败,跳过!'.format(source)) - else: - return source,template + template = ubuntu_ports_deb822_template if self.use_deb822 else ubuntu_ports_sources_template else: - return fast_source[0],template - return None,None - - for source in sources: - if "tsinghua" in source: - tsinghua_sources.append(source) - elif "ustc" in source: - ustc_sources.append(source) - elif "archive.ubuntu" in source or "deb.debian" in source: - official_sources.append(source) - elif "kernel" in source: - kernel_sources.append(source) - elif "aliyun" in source: - aliyun_sources.append(source) - else: - other_sources.append(source) - - # 添加到选择字典 - index = 1 - - # 添加清华源 - if tsinghua_sources: - for source in tsinghua_sources: - source_dict[index] = source - index += 1 - - # 添加中科大源 - if ustc_sources: - for source in ustc_sources: - source_dict[index] = source - index += 1 - - # 添加阿里云源 - if aliyun_sources: - for source in aliyun_sources: - source_dict[index] = source - index += 1 - - # 添加官方源 - if official_sources: - for source in official_sources: - source_dict[index] = source - index += 1 - - # 添加kernel源 - if kernel_sources: - for source in kernel_sources: - source_dict[index] = source - index += 1 - - # 添加其他源 - if other_sources: - for source in other_sources: - source_dict[index] = source - index += 1 - - PrintUtils.print_info("请选择您想使用的镜像源:") - code, source = ChooseTask(source_dict, "请选择一个镜像源").run() - - if not source: + mirrors = debian_mirrors + template = self.get_debian_template(codename) + + # 只测一次速, 换下一个源时直接用排好的顺序, 不再重新测 + key = (system, codename, arch) + if key not in self.ranking: + PrintUtils.print_delay('正在测试各个镜像源的下载速度, 为您挑选最快的:', 0.002) + ranked, eol = self.rank_mirrors(mirrors, codename) + if eol and system == 'ubuntu': + PrintUtils.print_warn('{} 已停止官方支持, 普通镜像里已经没有它了, 改用旧版本归档源'.format(codename)) + ranked, _ = self.rank_mirrors(ubuntu_old_mirrors, codename) + self.ranking[key] = ranked + + sources = [source for source in self.ranking[key] if source not in failed_sources] + if not sources: return None, None - - return source, template - - # 去除末尾的斜杠 - if source.endswith("/"): - source = source[:-1] - - return source, template - + if return_all: + return sources, template + return sources[0], template + def replace_source(self,failed_sources=[]): arch = AptUtils.getArch() name = osversion.get_name() @@ -251,115 +400,127 @@ def replace_source(self,failed_sources=[]): elif name.find("debian")>=0: system = 'debian' else: + PrintUtils.print_error('当前系统{}暂不支持一键换源'.format(name)) return None - + if not codename: + PrintUtils.print_error('获取系统代号失败, 无法自动换源, 请检查 /etc/os-release') + return None + PrintUtils.print_delay('检测到当前系统:{} 架构:{} 代号:{},正在为你搜索适合的源...'.format(system,arch,codename)) - + # 根据用户选择的方式获取源 - if hasattr(self, 'source_method_code'): - if self.source_method_code == 2: - # 根据测速结果手动选择源 - sorted_sources, template = self.get_source_by_system(system, codename, arch, failed_sources, return_all=True) - if not sorted_sources: - return None - - # 创建选择字典 - source_dict = {} - for i, src in enumerate(sorted_sources, 1): - source_dict[i] = src - - PrintUtils.print_info("请从测速结果中选择您想使用的镜像源:") - code, source = ChooseTask(source_dict, "请选择一个镜像源").run() - - if not source: - return None - else: - # 自动测速选择源 - source, template = self.get_source_by_system(system, codename, arch, failed_sources) + if getattr(self, 'source_method_code', 1) == 2: + # 根据测速结果手动选择源 + sorted_sources, template = self.get_source_by_system(system, codename, arch, failed_sources, return_all=True) + if not sorted_sources: + return None + source_dict = {} + for i, src in enumerate(sorted_sources, 1): + source_dict[i] = '{} {}'.format(self.source_names.get(src, ''), src) + PrintUtils.print_info("请从测速结果中选择您想使用的镜像源(已按速度从快到慢排列):") + code, _ = ChooseTask(source_dict, "请选择一个镜像源").run() + if code == 0: + self.cancelled = True + return None + source = sorted_sources[code - 1] + PrintUtils.print_success('您选择的镜像源:{}'.format(source_dict[code])) else: # 自动测速选择源 source, template = self.get_source_by_system(system, codename, arch, failed_sources) - - if not source: return None - - if hasattr(self, 'source_method_code'): - if self.source_method_code == 2: - PrintUtils.print_success('您选择的镜像源:{}'.format(source)) - else: - PrintUtils.print_success('为您选择最快镜像源:{}'.format(source)) + if not source: return None + PrintUtils.print_success('为您选择最快镜像源:{} {}'.format(self.source_names.get(source, ''), source)) + + data = template.replace("",codename).replace('',source.rstrip('/')) + if self.use_deb822: + FileUtils.new('/etc/apt/sources.list.d/','ubuntu.sources',data) else: - PrintUtils.print_success('为您选择最快镜像源:{}'.format(source)) - - # 使用已修复的 FileUtils.new 方法 - FileUtils.new('/etc/apt/','sources.list',template.replace("",codename).replace('',source)) + FileUtils.new('/etc/apt/','sources.list',data) return source - - def change_sys_source(self): - self.clean_old_source() + def change_sys_source(self, auto=False): + """ + 更换系统源. 高手模式先问两个问题, 手动选镜像时选 [0] 会撤销本次换源并退回前一个问题; + 在第一个问题选 [0] 时返回 BaseTool.BACK + """ + self.current_source = None + self.failed_sources = [] + step = 0 + while True: + if not auto and self.ask_source_options(step) == BaseTool.BACK: + return BaseTool.BACK + self.cancelled = False + self.clean_old_source(auto=auto) + source, result = self.apply_source(auto) + if not self.cancelled: + break + # 手动选镜像时选了返回上一步: 撤销本次换源, 回到"源的选择方式"这个问题 + self.restore_sources() + step = 1 + + if not source: + PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") + return + self.current_source = source if result[0] == 0 else None + if result[0]==0: + PrintUtils.print_success("搞定了,不信你看,累死宝宝了,还不快去给小鱼点个赞~") + PrintUtils.print_info(result[1]) + PrintUtils.print_success("镜像更新完成.....") + + def apply_source(self, auto=False): + """测速、写入镜像并更新索引, 更新失败时自动(手动模式下让用户)换下一个. 返回 (镜像地址, apt update 结果)""" failed_sources = [] + self.failed_sources = failed_sources + result = (-1, [], []) source = self.replace_source(failed_sources) if source: PrintUtils.print_delay("替换镜像源完成,尝试进行更新....") result = CmdTask('sudo apt update',100).run() - - # 如果是手动选择源且更新失败,提示用户重新选择 - if result[0] != 0 and hasattr(self, 'source_method_code') and self.source_method_code == 2: - PrintUtils.print_warn("您选择的源更新失败,请重新选择其他源") - while result[0] != 0: - failed_sources.append(source) - source = self.replace_source(failed_sources) - if not source: - PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") - return + # 全自动模式下保留了第三方源, 更新失败多半是它们的问题(密钥过期/源失效), 先挪走再试 + if result[0] != 0 and auto: + backup_dir = self.backup_third_party_sources() + if backup_dir: + PrintUtils.print_warn('更新失败, 已把可能有问题的第三方源备份到 {} 后重试'.format(backup_dir)) result = CmdTask('sudo apt update',100).run() - # 如果是自动测速选择源且更新失败,自动尝试其他源 - elif result[0] != 0: + if result[0] != 0: + if getattr(self, 'source_method_code', 1) == 2: + PrintUtils.print_warn("您选择的源更新失败,请重新选择其他源") while result[0] != 0: failed_sources.append(source) PrintUtils.print_warn("更新失败,尝试更换其他源") source = self.replace_source(failed_sources) if not source: - PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") - return + break result = CmdTask('sudo apt update',100).run() - else: - PrintUtils.print_error("没有找到合适的镜像源,臣妾告退!") + return source, result -# # update -# PrintUtils.print_delay("替换完成,尝试第一次更新....") -# result = CmdTask('sudo apt update',100).run() -# # https error update second -# if result[0]!= 0 and FileUtils.check_result(result[1]+result[2],['Certificate verification','证书']): -# PrintUtils.print_delay("发生证书错误,尝试第二次更新....") -# FileUtils.delete('/etc/apt/sources.list') -# FileUtils.new('/etc/apt/','sources.list',source.replace("https://","http://").replace("",osversion.get_codename())) -# result = CmdTask('sudo apt update',100).run() -# if result[0]!=0: -# PrintUtils.print_info("更新失败,开始更换导入方式并三次尝试...") -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 0E98404D386FA1D9",10).run() -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys DCC9EFBF77E11517",10).run() -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 54404762BBB6E853",10).run() -# result = CmdTask("sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F42ED6FBAB17C654",10).run() -# # sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys -# # result = CmdTask("apt-get install debian-keyring debian-archive-keyring",10).run() -# result = CmdTask("apt-key update",10).run() -# result = CmdTask('sudo apt update',100).run() -# if result[0]!=0: -# PrintUtils.print_info("""如果出现问题NO_PUBKEY XXXXXXXX,请手动运行添加指令:apt-key adv --keyserver keyserver.ubuntu.com --recv-keys XXXXXXXX -# 如:error: NO_PUBKEY 0E98404D386FA1D9 -# 运行指令:sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys 0E98404D386FA1D9 -# """) -# # final check - - if result[0]==0: - PrintUtils.print_success("搞定了,不信你看,累死宝宝了,还不快去给小鱼点个赞~") - PrintUtils.print_info(result[1]) - PrintUtils.print_success("镜像更新完成.....") + def switch_to_next_source(self): + """ + 全自动模式: 当前系统镜像在安装中途下载失败(限流/断连)时, 换下一个最快的镜像并更新索引. + 返回新镜像地址, 没有可用镜像时返回 None + """ + if not getattr(self, 'current_source', None): + return None + self.exclude_server(self.current_source) + source = self.replace_source(self.failed_sources) + while source: + if CmdTask('sudo apt update', 100).run()[0] == 0: + self.current_source = source + return source + self.exclude_server(source) + source = self.replace_source(self.failed_sources) + return None + + def exclude_server(self, source): + """同一台服务器的 http/https 地址一起排除, 否则"换镜像"可能只是换了个协议""" + address = source.split('://', 1)[-1] + self.failed_sources.extend(['http://' + address, 'https://' + address]) def run(self): # 正式的运行 - self.change_sys_source() - # 添加 ROS Source - self.add_ros_source() \ No newline at end of file + while True: + if self.change_sys_source() == BaseTool.BACK: + return BaseTool.BACK + # 在"是否添加ROS源"选了返回上一步: 回到换源的第一个问题重新选 + if self.add_ros_source() != BaseTool.BACK: + return diff --git a/tools/tool_install_cartographer.py b/tools/tool_install_cartographer.py index 3913d16..ff3e1cb 100644 --- a/tools/tool_install_cartographer.py +++ b/tools/tool_install_cartographer.py @@ -28,8 +28,10 @@ def install_docker(self): if ros_version!=None: PrintUtils.print_info("检测到您的系统版ROS版本为:{}".format(ros_version)) else: - PrintUtils.print_info("检测系统版本失败,请反馈以下信息到鱼香社区:Fail on check ros version with {}".format(osversion.get_version())) - return + # 不是 bug: 这里是编译 ROS1 版 Cartographer, 22.04 及以后没有 ROS1. 原来提示「检测失败请反馈」会让人以为脚本坏了 + PrintUtils.print_warn("一键编译Cartographer只支持ROS1(Ubuntu 16.04/18.04/20.04), 当前系统是Ubuntu {}".format(osversion.get_version())) + PrintUtils.print_info("ROS2用户请使用菜单[22]一键安装ROS常用功能包, 里面已包含Cartographer") + return False # check apt if not AptUtils.checkapt(): return False @@ -52,5 +54,5 @@ def install_docker(self): CmdTask("sudo chmod -R 777 cartographer_ws").run() def run(self): - self.install_docker() + return self.install_docker() diff --git a/tools/tool_install_chrome.py b/tools/tool_install_chrome.py new file mode 100644 index 0000000..aa49e8d --- /dev/null +++ b/tools/tool_install_chrome.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +from .base import BaseTool +from .base import PrintUtils, CmdTask, DownloadUtils +from .base import osarch + +# Google 官方 deb, 国内直连实测约 8MB/s(2026-11). 装上后会自动加 Google 的 apt 源, 之后 apt upgrade 就能升级 +CHROME_URL = "https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb" +CHROME_PKG = "google-chrome-stable" + + +class Tool(BaseTool): + def __init__(self): + self.type = BaseTool.TYPE_INSTALL + self.name = "一键安装Chrome浏览器" + self.author = '小鱼' + + @staticmethod + def installed_version(): + result = CmdTask("dpkg-query -W -f='${Status} ${Version}' " + CHROME_PKG).run() + status = " ".join(result[1]) + if result[0] == 0 and "install ok installed" in status: + return status.replace("install ok installed", "").strip() + return None + + def install_chrome(self): + # Ubuntu 22.04 自带的 snap 版 Firefox 在国内连不上 snap 商店、没法更新, 太老的版本打不开一些新网页(如 dsh web) + if osarch != "amd64": + PrintUtils.print_error("Google 只提供 amd64 版的 Linux Chrome, 当前系统架构是 {}, 请使用系统自带的浏览器".format(osarch)) + return False + version = self.installed_version() + if version: + PrintUtils.print_success("已经安装了 Chrome {}, 以后用 sudo apt update && sudo apt upgrade 即可升级".format(version)) + return True + PrintUtils.print_info("开始下载 Chrome 浏览器(约140MB)~") + temp_file = "/tmp/fishros_chrome.deb" + if not DownloadUtils.download_deb([("Google 官方", CHROME_URL)], temp_file): + CmdTask('rm -f "{}"'.format(temp_file)).run() + PrintUtils.print_error("Chrome 下载失败, 请检查网络后重试") + return False + # 用 apt 装本地包, 缺的依赖会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "{}"'.format(temp_file), 600, os_command=True).run() + CmdTask('rm -f "{}"'.format(temp_file)).run() + version = self.installed_version() + if not version: + PrintUtils.print_error("Chrome 安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("Chrome {} 安装完成".format(version)) + PrintUtils.print_info("启动: 在应用菜单中搜索 Chrome, 或终端运行 google-chrome") + PrintUtils.print_info("推荐设为默认浏览器: 首次打开 Chrome 时按提示设置, 或在 设置 -> 默认应用 -> 网页 里选 Google Chrome") + return True + + def run(self): + return self.install_chrome() diff --git a/tools/tool_install_docker.py b/tools/tool_install_docker.py index f7d4304..2b17f33 100644 --- a/tools/tool_install_docker.py +++ b/tools/tool_install_docker.py @@ -1,55 +1,134 @@ # -*- coding: utf-8 -*- +import os from .base import BaseTool from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask from .base import osversion, osarch from .base import run_tool_file +# 按顺序尝试, 哪个镜像的公钥能下下来就用哪个镜像的软件源(公钥和源要来自同一处) +docker_mirrors = [ + "https://mirrors.tuna.tsinghua.edu.cn/docker-ce", + "https://mirrors.ustc.edu.cn/docker-ce", + "https://mirrors.aliyun.com/docker-ce", +] +docker_key = "/etc/apt/keyrings/docker.asc" +docker_list = "/etc/apt/sources.list.d/docker.list" + + class Tool(BaseTool): def __init__(self): self.name = "一键安装Docker,支持amd64和arm64架构系统" self.type = BaseTool.TYPE_INSTALL self.author = 'alyssa' + @staticmethod + def docker_repo(): + """ + 返回 (源路径, 系统代号). 原来写死 linux/ubuntu + lsb_release -cs, Debian 路径不对, + Linux Mint 的 lsb_release 给的是 Mint 自己的代号(如 wilma), Docker 源里都没有 + """ + info = {} + try: + with open("/etc/os-release", "r") as f: + for line in f: + if "=" in line: + key, value = line.strip().split("=", 1) + info[key] = value.strip('"') + except Exception: + pass + # Ubuntu 及基于 Ubuntu 的发行版(Mint、Pop!_OS 等)都带 UBUNTU_CODENAME + if info.get("UBUNTU_CODENAME"): + return "ubuntu", info["UBUNTU_CODENAME"] + if info.get("ID") == "raspbian": + return "raspbian", info.get("VERSION_CODENAME") + if info.get("ID") == "debian" or "debian" in info.get("ID_LIKE", "").split(): + return "debian", info.get("VERSION_CODENAME") + return "ubuntu", info.get("VERSION_CODENAME") + + @staticmethod + def key_ok(): + try: + with open(docker_key, "r") as f: + return "BEGIN PGP PUBLIC KEY BLOCK" in f.read() + except Exception: + return False + + def remove_broken_source(self): + """ + 上次公钥没下载成功时会留下一个没有公钥的 docker.list, 之后每次 apt update 都报 NO_PUBKEY, + 下面的 checkapt 也会因此失败直接退出, 用户自己很难看出原因, 所以先清掉 + """ + if os.path.exists(docker_list) and not self.key_ok(): + PrintUtils.print_warn("检测到上次没装好留下的Docker软件源(缺少公钥), 先清理掉再重新安装~") + CmdTask("sudo rm -f {}".format(docker_list)).run() + + def download_key(self, distro): + """依次从各镜像下载公钥, 返回下载成功的镜像地址, 都失败返回 None""" + for mirror in docker_mirrors: + CmdTask('curl -fsSL --retry 3 --connect-timeout 15 --max-time 60 "{}/linux/{}/gpg" -o {}'.format(mirror, distro, docker_key)).run() + if self.key_ok(): + CmdTask('chmod a+r {}'.format(docker_key)).run() + return mirror + PrintUtils.print_warn("从{}下载Docker公钥失败, 换下一个镜像试试~".format(mirror)) + CmdTask("sudo rm -f {}".format(docker_key)).run() + return None + def install_docker(self): """ Executing Docker install script with updated commands for new installation steps. """ PrintUtils.print_info("开始根据系统架构,为你下载对应版本的docker~") + # osarch 会把 armhf 当成 arm64, 写软件源要用 dpkg 报的真实架构(32 位树莓派系统是 armhf) + arch = CmdTask("dpkg --print-architecture").run()[1][0].strip() + if arch not in ('amd64', 'arm64', 'armhf'): + PrintUtils.print_error("暂不支持当前系统架构:{}, 目前只支持amd64、arm64和armhf".format(arch)) + return False + distro, codename = self.docker_repo() + PrintUtils.print_info("将使用Docker的{}软件源, 系统代号:{}".format(distro, codename)) + self.remove_broken_source() # Check if apt is available if not AptUtils.checkapt(): return False # Pre-installation steps - CmdTask('apt-get update', 120, os_command=True).run() CmdTask('DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl ', 120, os_command=True).run() CmdTask('install -m 0755 -d /etc/apt/keyrings', 10, os_command=True).run() # Add Docker GPG key - CmdTask('curl -fsSL "https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu/gpg" -o /etc/apt/keyrings/docker.asc', 10, os_command=True).run() - CmdTask('chmod a+r /etc/apt/keyrings/docker.asc', 10,os_command=True).run() + mirror = self.download_key(distro) + if mirror is None: + PrintUtils.print_error("Docker公钥下载失败, 请检查网络后重试") + return False # Add Docker repository - if osarch == 'amd64': - CmdTask('echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list', os_command=True).run() - elif osarch == 'arm64': - CmdTask('echo "deb [arch=arm64 signed-by=/etc/apt/keyrings/docker.asc] https://mirrors.tuna.tsinghua.edu.cn/docker-ce/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list', os_command=True).run() - else: - return False + CmdTask('echo "deb [arch={} signed-by={}] {}/linux/{} {} stable" > {}'.format( + arch, docker_key, mirror, distro, codename, docker_list), os_command=True).run() # Update apt index and install Docker PrintUtils.print_info("下载完成,接下来升级apt索引~") - CmdTask('apt-get update ', 10,os_command=True).run() + if CmdTask('apt-get update', 120).run()[0] != 0: + # 源加上了却更新不了时撤掉, 免得影响之后所有 apt 操作 + PrintUtils.print_error("Docker软件源更新失败, 已撤销添加的软件源, 请检查网络后重试") + CmdTask("sudo rm -f {} {}".format(docker_list, docker_key)).run() + return False PrintUtils.print_info("开始安装最新版本docker CE~") CmdTask('DEBIAN_FRONTEND=noninteractive apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin docker-ce-rootless-extras docker-buildx-plugin', 120,os_command=True).run() + result = CmdTask('docker --version').run() + if result[0] != 0: + PrintUtils.print_error("Docker安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + # Post-installation steps - CmdTask('sudo groupadd docker', 10,os_command=True).run() + CmdTask('sudo groupadd -f docker', 10,os_command=True).run() users = FileUtils.getusers() for user in users: CmdTask('sudo gpasswd -a {} docker'.format(user), 10,os_command=True).run() - PrintUtils.print_info("安装完成,接下来你可以尝试使用docker --version指令测试是有正常回显~") + PrintUtils.print_success("Docker安装成功: {}".format(" ".join(result[1]))) + PrintUtils.print_info("当前用户需要重新登录(或重启)后才能不加sudo使用docker命令~") + return True def run(self): - self.install_docker() + return self.install_docker() diff --git a/tools/tool_install_github_desktop.py b/tools/tool_install_github_desktop.py index 130a398..c5867e9 100644 --- a/tools/tool_install_github_desktop.py +++ b/tools/tool_install_github_desktop.py @@ -1,25 +1,72 @@ # -*- coding: utf-8 -*- +import re from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask -from .base import osversion +from .base import osversion,osarch from .base import run_tool_file +# 原来走 github.fishros.org 中转下载写死的 2.9.12, 中转已不返回数据(wget 会卡几十分钟). +# 改用 shiftkey 项目 README 里给出的官方 apt 镜像, 国内实测约 1.7MB/s; 从它的软件包索引里取最新版下载, +# 不把它加成 apt 源: 镜像万一挂了, apt update 会一直报错, 连累其它工具 +mwt_mirror = "https://mirror.mwt.me/shiftkey-desktop/deb" +github_releases = "https://github.com/shiftkey/desktop/releases" + + class Tool(BaseTool): def __init__(self): self.type = BaseTool.TYPE_INSTALL self.name = "安装Github桌面版" self.author = '小鱼' + @staticmethod + def version_key(version): + return [int(n) for n in re.findall(r"\d+", version)] + + def latest_deb(self): + """读镜像的软件包索引, 返回最新版的 (版本号, 下载地址), 失败返回 None""" + result = CmdTask("wget -qO- -T 15 -t 3 {}/dists/any/main/binary-{}/Packages".format(mwt_mirror, osarch)).run() + if result[0] != 0: + return None + packages, current = [], {} + for line in result[1] + [""]: + if not line.strip(): + if "Version" in current and "Filename" in current: + packages.append(current) + current = {} + elif ":" in line: + key, value = line.split(":", 1) + current[key.strip()] = value.strip() + if len(packages) == 0: + return None + latest = max(packages, key=lambda p: self.version_key(p["Version"])) + return latest["Version"], "{}/{}".format(mwt_mirror, latest["Filename"]) + def install_github(self): - """ - wget http://github.fishros.org/https://github.com/shiftkey/desktop/releases/download/release-2.9.12-linux4/GitHubDesktop-linux-2.9.12-linux4.deb -O /tmp/github.deb - sudo dpkg -i /tmp/github.deb - sudo apt install /tmp/github.deb -y - """ - CmdTask('sudo wget http://github.fishros.org/https://github.com/shiftkey/desktop/releases/download/release-2.9.12-linux4/GitHubDesktop-linux-2.9.12-linux4.deb -O /tmp/github.deb',os_command=True).run() - CmdTask('sudo dpkg -i /tmp/github.deb').run() - CmdTask('sudo apt install /tmp/github.deb -y').run() + if osarch != "amd64": + # 镜像目前只有 amd64 版 + PrintUtils.print_error("国内镜像只提供amd64版本, {}版本请到 {} 手动下载安装".format(osarch, github_releases)) + return False + PrintUtils.print_info("正在获取GitHub桌面版最新版本~") + latest = self.latest_deb() + if latest is None: + PrintUtils.print_error("获取GitHub桌面版版本信息失败, 请检查网络后重试") + return False + version, url = latest + PrintUtils.print_info("开始下载GitHub桌面版 {}(约120MB)~".format(version)) + CmdTask('sudo wget -T 15 -t 3 {} -O /tmp/github.deb'.format(url), os_command=True).run() + if CmdTask("dpkg-deb -I /tmp/github.deb").run()[0] != 0: + PrintUtils.print_error("GitHub桌面版安装包下载失败, 请检查网络后重试") + CmdTask("rm -rf /tmp/github.deb").run() + return False + # 用 apt 装本地包, 缺的依赖会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt install -y /tmp/github.deb', os_command=True).run() + CmdTask("rm -rf /tmp/github.deb").run() + if "install ok installed" not in " ".join(CmdTask("dpkg-query -W -f='${Status}' github-desktop").run()[1]): + PrintUtils.print_error("GitHub桌面版安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("GitHub桌面版 {} 安装成功, 可以在应用列表里找到 GitHub Desktop~".format(version)) + return True def run(self): #正式的运行 - self.install_github() + return self.install_github() diff --git a/tools/tool_install_nodejs.py b/tools/tool_install_nodejs.py index 019873b..1ceb788 100644 --- a/tools/tool_install_nodejs.py +++ b/tools/tool_install_nodejs.py @@ -56,28 +56,28 @@ def _cleanup_legacy(self): ) def _choose_target_major(self): + # 18 已于 2025-04、20 已于 2026-04 停止维护, 只为老项目保留 version_dic = { - 1: "Node.js 22 LTS(默认推荐)", - 2: "Node.js 20 LTS", - 3: "Node.js 18 LTS(兼容)", - 4: "自定义主版本号", + 1: "Node.js 18(已停止维护,仅兼容老项目)", + 2: "Node.js 20(已停止维护,仅兼容老项目)", + 3: "Node.js 22 LTS", + 4: "Node.js 24 LTS(推荐)", + 5: "Node.js 26 LTS(最新)", + 6: "自定义主版本号", } - code, _ = ChooseTask(version_dic, "请选择Node.js默认版本:", False).run() - if code == 1: - return 22 - if code == 2: - return 20 - if code == 3: - return 18 - if code == 4: - custom = input("请输入Node.js主版本号(如22):").strip() + version_map = {1: 18, 2: 20, 3: 22, 4: 24, 5: 26} + code, _ = ChooseTask(version_dic, "请选择Node.js默认版本(不知道选哪个就选4):", False).run() + if code in version_map: + return version_map[code] + if code == 6: + custom = input("请输入Node.js主版本号(如24):").strip() if custom.isdigit() and int(custom) > 0: return int(custom) - PrintUtils.print_warn("输入无效,默认使用22") - return 22 + PrintUtils.print_warn("输入无效,默认使用24") + return 24 if os.environ.get("FISH_INSTALL_CONFIG") is not None: - PrintUtils.print_warn("自动化模式未提供Node.js版本,默认使用22") - return 22 + PrintUtils.print_warn("自动化模式未提供Node.js版本,默认使用24") + return 24 PrintUtils.print_warn("已取消Node.js安装") return None @@ -379,7 +379,7 @@ def _install_from_ustc_tar(self, target_major): downloaded = False for tar_url, cur_tar_name in tar_urls: download_result = self._run_cmd( - "wget --no-proxy {} -O /tmp/nodejs.tar.xz".format(tar_url), + "wget --no-proxy -T 15 -t 3 {} -O /tmp/nodejs.tar.xz".format(tar_url), 300, "下载Node.js安装包...", ) @@ -424,7 +424,7 @@ def _install_from_ustc_tar(self, target_major): self._run_cmd("rm -rf /tmp/nodejs.tar.xz", 10) return True - def install_nodejs_version(self, target_major=22, with_registry=True): + def install_nodejs_version(self, target_major=24, with_registry=True): self._cleanup_legacy() current_major = self._detect_node_major() if current_major is not None and current_major >= target_major: diff --git a/tools/tool_install_opencode.py b/tools/tool_install_opencode.py index ed89979..1df6487 100644 --- a/tools/tool_install_opencode.py +++ b/tools/tool_install_opencode.py @@ -1,20 +1,71 @@ # -*- coding: utf-8 -*- +# 菜单 [18]: 一键安装/卸载 AI 编程工具. 原来只装 OpenCode, 文件名沿用(菜单的工具路径是对外接口, 不改名) import os +import re from .base import BaseTool -from .base import PrintUtils, CmdTask, ChooseTask +from .base import PrintUtils, CmdTask, ChooseTask, DownloadUtils, AdUtils +from .base import osarch from .tool_install_nodejs import Tool as NodejsTool +# CC Switch 装完后提示里的中转模型供应商注册链接; 可在官网后台隐藏页 /admin/install/aff 配置, 留空则不提示 +DEFAULT_CC_SWITCH_RELAY_URL = "https://www.0809.one/sign-up?aff=8Zdc" + +# npm 安装的命令行工具. node 是需要的最低 Node.js 版本(取自各包 package.json 的 engines, 没写的按 20 算) +NPM_APPS = { + 1: {"name": "OpenCode", "tip": "OpenCode: 开源AI编程助手(命令行/网页)", + "pkg": "opencode-ai", "bin": "opencode", "node": (20, 0), + "usage": ["1.启动: 在项目目录下运行 opencode (网页版: opencode web)", + "2.第一次用: 在界面里输入 /connect, 选择模型供应商并填入 API Key(DeepSeek、Kimi、智谱等国内的也支持)"]}, + 2: {"name": "Codex", "tip": "Codex: OpenAI 的命令行编程助手", + "pkg": "@openai/codex", "bin": "codex", "node": (16, 0), + "usage": ["1.启动: 在项目目录下运行 codex", + "2.第一次用: 按提示登录 ChatGPT 账号或填 OpenAI API Key; 想用国内模型, 可以用菜单[18]->[6] CC Switch 一键配置"]}, + 3: {"name": "Claude Code", "tip": "Claude Code: Anthropic 的命令行编程助手", + "pkg": "@anthropic-ai/claude-code", "bin": "claude", "node": (22, 0), + "usage": ["1.启动: 在项目目录下运行 claude", + "2.第一次用: 登录 Anthropic 账号; 想用国内模型(DeepSeek 等提供的 Anthropic 兼容接口), 可以用菜单[18]->[6] CC Switch 一键配置"]}, + 4: {"name": "DeepSeek Harness", "tip": "DeepSeek Harness(dsh): DeepSeek 官方的智能体框架(预览版)", + "pkg": "@deepseek-ai/dsh", "bin": "dsh", "node": (20, 0), + "usage": ["1.启动: 在项目目录下运行 dsh web (直接输入 dsh 会报 --profile is required)", + "2.它会自动用默认浏览器打开网页; 没打开的话, 把终端里打印的完整地址 http://127.0.0.1:3080/?token=... 复制到浏览器(要带上 token)", + " 打开后点「请选择模型」, 配置 DeepSeek API Key 才能对话; 工作区就是运行 dsh web 时所在的目录", + "3.网页需要较新的浏览器: Ubuntu 22.04 自带的 snap 版 Firefox 在国内一般没法更新,", + " 打开后报 Intl.Segmenter is not a constructor 的话, 请改用 Chrome(一键安装主菜单[23]可安装),", + " 并把 Chrome 设为默认浏览器(设置 -> 默认应用), 或者把地址复制到 Chrome 里打开", + "4.不开网页, 单次执行任务: dsh --profile headless \"要做的事\"; 更多用法: dsh --help", + "5.目前是预览版, 用法可能会变"]}, + 5: {"name": "Pi", "tip": "Pi: 极简、可扩展的命令行编程助手", + "pkg": "@earendil-works/pi-coding-agent", "bin": "pi", "node": (22, 19), + "usage": ["1.启动: 在项目目录下运行 pi", + "2.第一次用: 在界面里输入 /login 登录模型账号(或先设好 API Key 环境变量), 再用 /model 选择模型"]}, +} +# 命令行工具都要先接入模型账号或 API Key 才能干活, 装完统一提醒一句 +NPM_COMMON_USAGE = "提示: 这些 AI 编程工具都要先接入模型账号或 API Key 才能使用, 打开界面后按上面的第一次使用说明配置" +# 从 GitHub Releases 下载 deb 安装的图形界面工具. tag 是自动获取最新版失败时用的版本; +# file 里的 {tag} 是带 v 的版本号, {ver} 是不带 v 的; arch 是系统架构到安装包命名的对应 +DEB_APPS = { + 6: {"name": "CC Switch", "tip": "CC Switch: 图形界面, 一键切换 Claude Code/Codex 等工具的模型供应商", + "repo": "farion1231/cc-switch", "tag": "v4.0.24", "deb": "cc-switch", + "file": "CC-Switch-{tag}-Linux-{arch}.deb", "arch": {"amd64": "x86_64", "arm64": "arm64"}, + "usage": ["1.启动: 在应用菜单中搜索 CC Switch, 或终端运行 cc-switch", + "2.添加相应工具的模型供应商即可使用{relay}"], + "relay": {"link_name": "cc_switch_relay", "default": DEFAULT_CC_SWITCH_RELAY_URL, + "text": "(如需中转模型供应商, 可点击 {} 获取)"}}, +} + class Tool(BaseTool): NPM_MIRROR = "https://registry.npmmirror.com" - NPM_TAOBAO = "https://registry.npm.taobao.org" + # 淘宝源早已迁到 npmmirror, 旧域名 registry.npm.taobao.org 2024 年起停用, 连不上只会白白多试一次 + NPM_TAOBAO = "https://registry.npmmirror.com" PREFERRED_NRM_ALIASES = ["taobao", "tencent", "huawei", "npm", "yarn", "cnpm"] def __init__(self): - self.name = "一键安装OpenCode" + self.name = "一键安装AI编程工具" self.type = BaseTool.TYPE_INSTALL self.author = "小鱼" + self.app = NPM_APPS[1] def _run_cmd(self, cmd, timeout=0, msg=None): if msg: @@ -94,24 +145,26 @@ def _fix_npm_prefix(self): return False return True - def _cleanup_bad_opencode_link(self): - target_result = self._run_cmd("readlink -f /usr/local/bin/opencode", 10) + def _cleanup_bad_link(self): + link = "/usr/local/bin/{}".format(self.app["bin"]) + target_result = self._run_cmd("readlink -f {}".format(link), 10) if self._code(target_result) != 0 or len(self._out(target_result)) == 0: return True target = str(self._out(target_result)[0]).strip() if target.startswith("/root/"): - PrintUtils.print_warn("检测到opencode链接指向/root,清理旧链接...") - rm_result = self._run_cmd("sudo rm -f /usr/local/bin/opencode", 10) + PrintUtils.print_warn("检测到{}链接指向/root,清理旧链接...".format(self.app["bin"])) + rm_result = self._run_cmd("sudo rm -f {}".format(link), 10) return self._code(rm_result) == 0 return True - def _ensure_opencode_command(self): - user_check = self._run_cmd("command -v opencode", 10) + def _ensure_command(self): + cmd = self.app["bin"] + user_check = self._run_cmd("command -v {}".format(cmd), 10) if self._code(user_check) == 0: return True prefix_result = self._run_cmd( - "sudo npm prefix -g", 10, "定位OpenCode安装目录..." + "sudo npm prefix -g", 10, "定位{}安装目录...".format(self.app["name"]) ) prefix_out = self._out(prefix_result) if self._code(prefix_result) != 0 or len(prefix_out) == 0: @@ -119,21 +172,24 @@ def _ensure_opencode_command(self): npm_prefix = str(prefix_out[0]).strip() if npm_prefix.startswith("/root"): - PrintUtils.print_warn("OpenCode安装目录位于/root,普通用户可能无法访问") + PrintUtils.print_warn("{}安装目录位于/root,普通用户可能无法访问".format(self.app["name"])) return False link_result = self._run_cmd( - "sudo ln -sf {}/bin/opencode /usr/local/bin/opencode".format(npm_prefix), + "sudo ln -sf {}/bin/{} /usr/local/bin/{}".format(npm_prefix, cmd, cmd), 10, - "写入opencode命令到系统PATH...", + "写入{}命令到系统PATH...".format(cmd), ) if self._code(link_result) != 0: return False - verify = self._run_cmd("command -v opencode", 10) + verify = self._run_cmd("command -v {}".format(cmd), 10) return self._code(verify) == 0 def _remove_local_opencode(self): + # 只有 OpenCode 有官方脚本装到 ~/.opencode 的情况 + if self.app["pkg"] != "opencode-ai": + return True local_dir = os.path.expanduser("~/.opencode") local_bin = os.path.join(local_dir, "bin", "opencode") if not os.path.exists(local_bin): @@ -152,25 +208,26 @@ def _remove_local_opencode(self): return True def _install_with_registry_fallback(self, registry_ready): + pkg, name = self.app["pkg"], self.app["name"] if registry_ready: install_cmds = [ ( - "sudo npm i -g opencode-ai", - "安装OpenCode中(使用已切换的npm源)...", + "sudo npm i -g {}".format(pkg), + "安装{}中(使用已切换的npm源)...".format(name), ), ( - "sudo npm i -g opencode-ai --registry={}".format(self.NPM_MIRROR), + "sudo npm i -g {} --registry={}".format(pkg, self.NPM_MIRROR), "当前npm源安装失败,使用国内镜像参数重试...", ), ] else: install_cmds = [ ( - "sudo npm i -g opencode-ai --registry={}".format(self.NPM_MIRROR), - "安装OpenCode中(优先使用国内npm镜像)...", + "sudo npm i -g {} --registry={}".format(pkg, self.NPM_MIRROR), + "安装{}中(优先使用国内npm镜像)...".format(name), ), ( - "sudo npm i -g opencode-ai", + "sudo npm i -g {}".format(pkg), "国内npm镜像安装失败,回退官方源重试...", ), ] @@ -180,14 +237,14 @@ def _install_with_registry_fallback(self, registry_ready): if self._code(result) == 0: return True if self._has_text(result, "ENOTEMPTY"): - PrintUtils.print_warn("检测到旧版OpenCode残留目录,开始自动清理后重试") - if self._cleanup_global_opencode(): - retry_result = self._run_cmd(cmd, 300, "重新安装OpenCode中...") + PrintUtils.print_warn("检测到旧版{}残留目录,开始自动清理后重试".format(name)) + if self._cleanup_global_install(): + retry_result = self._run_cmd(cmd, 300, "重新安装{}中...".format(name)) if self._code(retry_result) == 0: return True return False - def _cleanup_global_opencode(self): + def _cleanup_global_install(self): prefix_result = self._run_cmd("sudo npm prefix -g", 10, "定位npm全局目录...") prefix_out = self._out(prefix_result) if self._code(prefix_result) != 0 or len(prefix_out) == 0: @@ -196,16 +253,19 @@ def _cleanup_global_opencode(self): npm_prefix = str(prefix_out[0]).strip() module_root = "{}/lib/node_modules".format(npm_prefix) - cleanup_cmd = ( - "sudo rm -rf " - "{root}/opencode-ai " - "{root}/.opencode-ai-* " - "/usr/local/bin/opencode" - ).format(root=module_root) + # npm 升级中断会留下「.包名-随机串」临时目录; 带 scope 的包(@openai/codex)临时目录在 scope 目录里 + pkg = self.app["pkg"] + scope, _, base = pkg.rpartition("/") + if scope: + tmp_dir = "{}/{}/.{}-*".format(module_root, scope, base) + else: + tmp_dir = "{}/.{}-*".format(module_root, base) + cleanup_cmd = "sudo rm -rf {root}/{pkg} {tmp} /usr/local/bin/{bin}".format( + root=module_root, pkg=pkg, tmp=tmp_dir, bin=self.app["bin"]) cleanup_result = self._run_cmd( cleanup_cmd, 30, - "清理OpenCode全局残留目录...", + "清理{}全局残留目录...".format(self.app["name"]), ) return self._code(cleanup_result) == 0 @@ -236,7 +296,7 @@ def _switch_npm_registry(self): sources = node_tool._get_nrm_sources() taobao_url = self.NPM_TAOBAO - if "taobao" in sources and str(sources["taobao"]).strip() != taobao_url: + if "taobao" in sources and str(sources["taobao"]).strip().rstrip("/") != taobao_url: PrintUtils.print_warn( "检测到nrm中的taobao源为{},将优先使用固定taobao地址{}".format( sources["taobao"], taobao_url @@ -320,28 +380,41 @@ def _switch_npm_registry(self): self._run_cmd("npm config get registry", 10, "当前npm源:") return False + def _node_version(self): + result = self._run_cmd("node -v", 10, "检查Node.js版本...") + match = re.match(r"v(\d+)\.(\d+)", " ".join(self._out(result)).strip()) + if self._code(result) != 0 or not match: + return None + return int(match.group(1)), int(match.group(2)) + def _ensure_nodejs(self): - check_node = self._run_cmd("node -v", 10, "检查Node.js版本...") + need = self.app["node"] + version = self._node_version() check_npm = self._run_cmd("npm -v", 10, "检查npm版本...") check_sudo_npm = self._run_cmd("sudo npm -v", 10, "检查sudo环境npm...") if ( - self._code(check_node) == 0 + version is not None and self._code(check_npm) == 0 and self._code(check_sudo_npm) == 0 ): - return True - PrintUtils.print_warn("检测到Node.js/npm环境不完整,将自动安装Node.js 22") - return NodejsTool().install_nodejs_version(22, with_registry=False) + if version >= need: + return True + PrintUtils.print_warn("{}需要Node.js {}.{}以上, 当前是 {}.{}, 将自动安装Node.js 24".format( + self.app["name"], need[0], need[1], version[0], version[1])) + else: + PrintUtils.print_warn("检测到Node.js/npm环境不完整,将自动安装Node.js 24") + return NodejsTool().install_nodejs_version(24, with_registry=False) - def install_opencode(self): + def install_npm_app(self): + name, cmd = self.app["name"], self.app["bin"] if not self._ensure_nodejs(): - PrintUtils.print_error("Node.js环境准备失败,无法安装OpenCode") + PrintUtils.print_error("Node.js环境准备失败,无法安装{}".format(name)) return False if not self._fix_npm_prefix(): PrintUtils.print_warn("npm全局目录修复失败,安装可能受影响") - if not self._cleanup_bad_opencode_link(): - PrintUtils.print_warn("旧版opencode命令链接清理失败") + if not self._cleanup_bad_link(): + PrintUtils.print_warn("旧版{}命令链接清理失败".format(cmd)) self._print_runtime_diagnostics() @@ -350,73 +423,133 @@ def install_opencode(self): PrintUtils.print_warn("npm源切换失败,将直接使用镜像参数继续安装") if not self._install_with_registry_fallback(registry_ready): - PrintUtils.print_error("OpenCode安装失败,请检查npm网络、镜像可用性和权限") + PrintUtils.print_error("{}安装失败,请检查npm网络、镜像可用性和权限".format(name)) return False - if not self._ensure_opencode_command(): - PrintUtils.print_warn("未能自动修复opencode命令路径,将继续进行安装校验") + if not self._ensure_command(): + PrintUtils.print_warn("未能自动修复{}命令路径,将继续进行安装校验".format(cmd)) - verify = self._run_cmd("opencode --version", 10, "验证OpenCode版本...") + verify = self._run_cmd("{} --version".format(cmd), 10, "验证{}版本...".format(name)) if self._code(verify) != 0: verify = self._run_cmd( - "sudo npm list -g opencode-ai --depth=0", + "sudo npm list -g {} --depth=0".format(self.app["pkg"]), 20, - "验证OpenCode安装状态...", + "验证{}安装状态...".format(name), ) if self._code(verify) != 0: - PrintUtils.print_error("OpenCode验证失败,请重新执行安装") + PrintUtils.print_error("{}验证失败,请重新执行安装".format(name)) return False - PrintUtils.print_success("OpenCode安装完成") - PrintUtils.print_info("可通过命令 opencode 启动") - PrintUtils.print_info("可通过命令 opencode web 打开网页使用") + PrintUtils.print_success("{}安装完成".format(name)) + PrintUtils.print_info("==========使用方法===========") + for line in self.app["usage"]: + PrintUtils.print_info(line) + PrintUtils.print_warn(NPM_COMMON_USAGE) return True - def uninstall_opencode(self): + def uninstall_npm_app(self): + name, cmd, pkg = self.app["name"], self.app["bin"], self.app["pkg"] self._run_cmd( - "sudo rm -f /usr/local/bin/opencode", 10, "清理opencode命令链接..." + "sudo rm -f /usr/local/bin/{}".format(cmd), 10, "清理{}命令链接...".format(cmd) ) local_removed = self._remove_local_opencode() npm_installed = self._run_cmd( - "sudo npm list -g opencode-ai --depth=0", + "sudo npm list -g {} --depth=0".format(pkg), 20, - "检查npm全局OpenCode安装...", + "检查npm全局{}安装...".format(name), ) if self._code(npm_installed) == 0: uninstall_result = self._run_cmd( - "sudo npm uninstall -g opencode-ai", + "sudo npm uninstall -g {}".format(pkg), 120, - "卸载OpenCode中...", + "卸载{}中...".format(name), ) if self._code(uninstall_result) != 0: - PrintUtils.print_error("npm全局OpenCode卸载失败,请检查npm权限或网络") + PrintUtils.print_error("npm全局{}卸载失败,请检查npm权限或网络".format(name)) return False else: - PrintUtils.print_info("未检测到npm全局安装的OpenCode,跳过npm卸载") + PrintUtils.print_info("未检测到npm全局安装的{},跳过npm卸载".format(name)) - verify = self._run_cmd("opencode --version", 10, "验证OpenCode是否已卸载...") + verify = self._run_cmd("command -v {}".format(cmd), 10, "验证{}是否已卸载...".format(name)) if self._code(verify) == 0: PrintUtils.print_warn( - "检测到opencode命令仍存在,请检查PATH中的其他安装位置" + "检测到{}命令仍存在,请检查PATH中的其他安装位置".format(cmd) ) return False if not local_removed: return False - PrintUtils.print_success("OpenCode卸载完成") + PrintUtils.print_success("{}卸载完成".format(name)) + return True + + def _deb_installed(self, pkg): + result = self._run_cmd("dpkg-query -W -f='${Status}' " + pkg, 10) + return "install ok installed" in " ".join(self._out(result)) + + def install_deb_app(self): + app = self.app + if osarch not in app["arch"]: + PrintUtils.print_error("{}暂不支持当前系统架构:{}".format(app["name"], osarch)) + return False + PrintUtils.print_info("正在获取{}最新版本...".format(app["name"])) + tag = DownloadUtils.github_latest_tag(app["repo"]) + if tag is None: + tag = app["tag"] + PrintUtils.print_warn("获取最新版本失败, 安装已知版本 {}".format(tag)) + file_name = app["file"].format(tag=tag, ver=tag.lstrip("v"), arch=app["arch"][osarch]) + PrintUtils.print_info("准备安装: {} {}".format(app["name"], tag)) + temp_file = "/tmp/fishros_ai_tool.deb" + if not DownloadUtils.download_deb(DownloadUtils.github_sources(app["repo"], tag, file_name), temp_file): + self._run_cmd('rm -f "{}"'.format(temp_file), 10) + PrintUtils.print_error("{}下载失败,请检查网络后重试".format(app["name"])) + return False + # 用 apt 装本地包, 缺的依赖(webkit2gtk 等)会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "{}"'.format(temp_file), 600, os_command=True).run() + self._run_cmd('rm -f "{}"'.format(temp_file), 10) + if not self._deb_installed(app["deb"]): + PrintUtils.print_error("{}安装失败(Ubuntu 22.04以下的系统可能缺少依赖), 请把上面的报错信息发到鱼香社区求助".format(app["name"])) + return False + PrintUtils.print_success("{}安装完成".format(app["name"])) + PrintUtils.print_info("==========使用方法===========") + usage = app["usage"] + relay = app.get("relay") + if relay: + # 中转供应商链接从官网后台读, 后台留空就不提示这一段 + url = AdUtils.link(relay["link_name"], relay["default"]) + suffix = relay["text"].format(url) if url else "" + usage = [line.replace("{relay}", suffix) for line in usage] + for line in usage: + PrintUtils.print_info(line) + return True + + def uninstall_deb_app(self): + app = self.app + if not self._deb_installed(app["deb"]): + PrintUtils.print_info("未检测到已安装的{}".format(app["name"])) + return True + CmdTask("sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y {}".format(app["deb"]), 300, os_command=True).run() + if self._deb_installed(app["deb"]): + PrintUtils.print_error("{}卸载失败".format(app["name"])) + return False + PrintUtils.print_success("{}卸载完成".format(app["name"])) return True def run(self): - action_dic = { - 1: "安装OpenCode", - 2: "卸载OpenCode", - } - code, _ = ChooseTask(action_dic, "请选择操作:", False).run() - if code == 1: - self.install_opencode() - elif code == 2: - self.uninstall_opencode() - else: - PrintUtils.print_warn("已取消OpenCode操作") + apps = dict(NPM_APPS) + apps.update(DEB_APPS) + while True: + code, _ = ChooseTask({k: v["tip"] for k, v in apps.items()}, "请选择AI编程工具:", False).run() + if code == 0: + return BaseTool.BACK + if code not in apps: + continue + self.app = apps[code] + action, _ = ChooseTask({1: "安装" + self.app["name"], 2: "卸载" + self.app["name"]}, "请选择操作:", False).run() + if action == 0: + continue + is_deb = code in DEB_APPS + if action == 1: + return self.install_deb_app() if is_deb else self.install_npm_app() + return self.uninstall_deb_app() if is_deb else self.uninstall_npm_app() diff --git a/tools/tool_install_proxy_tool.py b/tools/tool_install_proxy_tool.py index 304397f..b5fc1c3 100644 --- a/tools/tool_install_proxy_tool.py +++ b/tools/tool_install_proxy_tool.py @@ -3,16 +3,47 @@ from urllib.parse import quote from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, ChooseTask +from .base import PrintUtils, CmdTask, FileUtils, ChooseTask, DownloadUtils, AdUtils from .base import osarch +# 可选的客户端. port 是各客户端默认的混合代理端口, 写进 proxy_add 快捷命令 +# (Clash Verge Rev 默认是 7897 不是 7890, 原来一律写 7890, 装它的用户 proxy_add 用不了) +CLIENTS = { + 1: { + "name": "FlClash", + "tip": "FlClash: 有界面, 安装包最小(约40MB); 虚拟机里可能白屏, 建议改用 Clash Verge", + "repo": "chen08209/FlClash", + "tag": "v0.9.2", + "cdn_dir": "FlClash/v0.9.2", + "file": "FlClash-0.9.2-linux-{arch}.deb", + "port": 7890, + "cmds": ["FlClash", "flclash"], + "deb": "flclash", + # Flutter 应用在虚拟机的 vmware/virtualbox 显卡上默认渲染会白屏, 待确认有效的开关后再填 + "desktop_env": "", + }, + 2: { + "name": "Clash Verge Rev", + "tip": "Clash Verge Rev(推荐): 有界面(约100MB), 虚拟机/真机都能正常显示", + "repo": "clash-verge-rev/clash-verge-rev", + "tag": "v2.5.8", + "cdn_dir": "clash-verge-rev/Clash%20Verge%20Rev%20v2.5.8", + "file": "Clash.Verge_2.5.8_{arch}.deb", + "port": 7897, + "cmds": ["clash-verge", "clash-verge-rev"], + "deb": "clash-verge", + # WebKitGTK 的 DMABUF 渲染在 vmware/virtualbox 虚拟机显卡上会白屏, 关掉它改走更稳的渲染路径; + # 真机加上只是少用一个 GPU 优化, 无副作用, 所以默认都加(2026-11 在 136 VMware 实测有效) + "desktop_env": "WEBKIT_DISABLE_DMABUF_RENDERER=1", + }, +} + + class Tool(BaseTool): - CLASH_VERGE_BASE = ( - "https://repo.trojan-cdn.com/clash-verge-rev/Clash%20Verge%20Rev%20v2.4.6" - ) - MIHOMO_PARTY_BASE = "https://repo.trojan-cdn.com/mihomo-party/v1.9.2" - RECOMMEND_SUBSCRIBE_URL = "https://fishros.org.cn/forum/topic/668" + # 推荐的订阅注册链接在官网后台隐藏页 /admin/install/aff 里配置, 读不到官网时用这个默认值. + # (原来指向论坛帖子 topic/668, 该帖 2024-10 已删除; 帖子里的 .au 注册地址国内也已连不上) + DEFAULT_SUBSCRIBE_URL = "https://portal.shadowsocks.nz/aff.php?aff=41638" def __init__(self): self.name = "一键安装 Linux 代理科学上网工具" @@ -41,51 +72,22 @@ def _out(self, result): return [] def _choose_install_mode(self): - mode_dic = { - 1: "有界面版: Clash Verge Rev", - 2: "无界面版(按提供源): mihomo-party", - } - code, _ = ChooseTask(mode_dic, "请选择代理工具版本:", False).run() + mode_dic = {code: client["tip"] for code, client in CLIENTS.items()} + code, _ = ChooseTask(mode_dic, "请选择代理工具(不知道选哪个就选2 Clash Verge):", False).run() return code - def _resolve_package(self, mode): - if osarch not in ["amd64", "arm64"]: - return None, None, None - - if mode == 1: - filename_map = { - "amd64": "Clash.Verge_2.4.6_amd64.deb", - "arm64": "Clash.Verge_2.4.6_arm64.deb", - } - file_name = filename_map[osarch] - return ( - "Clash Verge Rev", - "{}/{}".format(self.CLASH_VERGE_BASE, file_name), - file_name, - ) + def _download_package(self, client, path): + """GitHub 加速站在前, repo.trojan-cdn.com 兜底(一直能下, 但国内只有十几 KB/s)""" + file_name = client["file"].format(arch=osarch) + sources = DownloadUtils.github_sources(client["repo"], client["tag"], file_name) + sources = [s for s in sources if s[0] != "GitHub 直连"] # 直连基本下不动, 用 trojan-cdn 兜底 + sources.append(("trojan-cdn 镜像", "https://repo.trojan-cdn.com/{}/{}".format(client["cdn_dir"], file_name))) + return DownloadUtils.download_deb(sources, path) - if mode == 2: - filename_map = { - "amd64": "mihomo-party-linux-1.9.2-amd64.deb", - "arm64": "mihomo-party-linux-1.9.2-arm64.deb", - } - file_name = filename_map[osarch] - return ( - "mihomo-party", - "{}/{}".format(self.MIHOMO_PARTY_BASE, file_name), - file_name, - ) - - return None, None, None - - def _install_deb_package(self, package_url): + def _install_deb_package(self, client): temp_file = "/tmp/fishros_proxy_tool.deb" - download = self._run_cmd( - 'wget --no-proxy "{}" -O "{}"'.format(package_url, temp_file), - 300, - "下载代理工具安装包中...", - ) - if self._code(download) != 0: + if not self._download_package(client, temp_file): + self._run_cmd('rm -f "{}"'.format(temp_file), 10) return False install = self._run_cmd( @@ -127,10 +129,15 @@ def _install_deb_package(self, package_url): self._run_cmd('rm -f "{}"'.format(temp_file), 10) return True + def _recommend_url(self): + return AdUtils.link("proxy_subscribe", self.DEFAULT_SUBSCRIBE_URL) + def _read_input_subscription(self): - PrintUtils.print_warn( - "请输入订阅地址(若无请访问 {} 获取)".format(self.RECOMMEND_SUBSCRIBE_URL) - ) + recommend = self._recommend_url() + if recommend: + PrintUtils.print_warn("请输入订阅地址(若无请访问 {} 获取)".format(recommend)) + else: + PrintUtils.print_warn("请输入订阅地址") return input("订阅地址:").strip() def _auto_import_subscription(self, subscribe_url): @@ -147,32 +154,34 @@ def _auto_import_subscription(self, subscribe_url): PrintUtils.print_info(clash_link) return False - def _setup_proxy_alias(self, user_home): + def _setup_proxy_alias(self, user_home, port): bashrc = os.path.join(user_home.rstrip("/"), ".bashrc") if not os.path.exists(bashrc): FileUtils.new(user_home.rstrip("/") + "/", name=".bashrc", data="") start_flag = "# >>> fishros proxy alias >>>" end_flag = "# <<< fishros proxy alias <<<" + # 端口是所选客户端的默认端口, 在客户端里改过端口的话改这里的 FISHROS_PROXY_PORT 即可 block = ( - "{}\n" - "proxy_add(){\n" - " export http_proxy=http://127.0.0.1:7890\n" - " export https_proxy=http://127.0.0.1:7890\n" - " export all_proxy=socks5://127.0.0.1:7890\n" - " echo 'proxy on: 127.0.0.1:7890'\n" - "}\n" - "proxy_off(){\n" + "{start}\n" + "FISHROS_PROXY_PORT={port}\n" + "proxy_add(){{\n" + " export http_proxy=http://127.0.0.1:$FISHROS_PROXY_PORT\n" + " export https_proxy=http://127.0.0.1:$FISHROS_PROXY_PORT\n" + " export all_proxy=socks5://127.0.0.1:$FISHROS_PROXY_PORT\n" + " echo \"proxy on: 127.0.0.1:$FISHROS_PROXY_PORT\"\n" + "}}\n" + "proxy_off(){{\n" " unset http_proxy https_proxy all_proxy\n" " echo 'proxy off'\n" - "}\n" - "proxy_status(){\n" + "}}\n" + "proxy_status(){{\n" ' echo "http_proxy=$http_proxy"\n' ' echo "https_proxy=$https_proxy"\n' ' echo "all_proxy=$all_proxy"\n' - "}\n" - "{}" - ).format(start_flag, end_flag) + "}}\n" + "{end}" + ).format(start=start_flag, end=end_flag, port=port) with open(bashrc, "r") as f: data = f.read() @@ -189,7 +198,7 @@ def _setup_proxy_alias(self, user_home): with open(bashrc, "w") as f: f.write(data) - PrintUtils.print_success("已写入终端快捷命令: proxy_add/proxy_off/proxy_status") + PrintUtils.print_success("已写入终端快捷命令: proxy_add/proxy_off/proxy_status(端口 {})".format(port)) def _which(self, name): result = self._run_cmd("command -v {}".format(name), 10) @@ -197,26 +206,33 @@ def _which(self, name): return str(self._out(result)[0]).strip() return None - def _print_launch_tips(self, mode): + def _patch_desktop_env(self, client): + """ + 给桌面启动项的 Exec 前加上 env 变量, 解决这些 GUI 在虚拟机显卡上白屏的问题. + 从应用菜单点图标启动时才会读 .desktop, 所以要改这里(终端直接运行命令的用户需要自己带变量). + 幂等: 已经加过就不再加 + """ + env = client.get("desktop_env") + deb = client.get("deb") + if not env or not deb: + return + # .desktop 文件名可能带空格(如 "Clash Verge.desktop"), 用 while read 逐行处理 + cmd = ( + "dpkg -L {deb} 2>/dev/null | grep -E '/applications/.*\\.desktop$' | while read -r f; do " + "grep -q 'env {env}' \"$f\" || sudo sed -i 's#^Exec=#Exec=env {env} #' \"$f\"; " + "done; sudo update-desktop-database >/dev/null 2>&1 || true" + ).format(deb=deb, env=env) + self._run_cmd(cmd, 20, "优化 {} 在虚拟机里的启动方式(避免界面白屏)...".format(client["name"])) + + def _print_launch_tips(self, client): PrintUtils.print_delay("==========使用方法===========") - if mode == 1: - cmd_path = self._which("clash-verge") - if cmd_path is None: - cmd_path = self._which("clash-verge-rev") - if cmd_path: - PrintUtils.print_delay("1.启动有界面客户端: {}".format(cmd_path)) - else: - PrintUtils.print_delay( - "1.启动有界面客户端: 在应用菜单中搜索 Clash Verge" - ) + cmd_path = None + for cmd in client["cmds"]: + cmd_path = cmd_path or self._which(cmd) + if cmd_path: + PrintUtils.print_delay("1.启动客户端: 在应用菜单中搜索 {}, 或终端运行 {}".format(client["name"], cmd_path)) else: - cmd_path = self._which("mihomo-party") - if cmd_path is None: - cmd_path = self._which("clash-party") - if cmd_path: - PrintUtils.print_delay("1.启动工具: {}".format(cmd_path)) - else: - PrintUtils.print_delay("1.启动工具: 在应用菜单中搜索 mihomo-party") + PrintUtils.print_delay("1.启动客户端: 在应用菜单中搜索 {}".format(client["name"])) PrintUtils.print_delay("2.开启终端代理: proxy_add") PrintUtils.print_delay("3.关闭终端代理: proxy_off") @@ -225,41 +241,43 @@ def _print_launch_tips(self, mode): def install_proxy_tool(self): mode = self._choose_install_mode() - if mode not in [1, 2]: + if mode == 0: + return BaseTool.BACK + if mode not in CLIENTS: PrintUtils.print_warn("已取消安装") return False - - package_name, package_url, file_name = self._resolve_package(mode) - if package_name is None: + if osarch not in ["amd64", "arm64"]: PrintUtils.print_error("当前仅支持 amd64/arm64 架构") return False + client = CLIENTS[mode] PrintUtils.print_info("当前架构: {}".format(osarch)) - PrintUtils.print_info("准备安装: {} ({})".format(package_name, file_name)) - if not self._install_deb_package(package_url): + PrintUtils.print_info("准备安装: {} {}".format(client["name"], client["tag"])) + if not self._install_deb_package(client): PrintUtils.print_error("安装失败,请检查网络、系统软件源或依赖修复状态") return False - PrintUtils.print_success("{} 安装完成".format(package_name)) + PrintUtils.print_success("{} 安装完成".format(client["name"])) + self._patch_desktop_env(client) subscribe_url = self._read_input_subscription() if subscribe_url: self._auto_import_subscription(subscribe_url) - else: + elif self._recommend_url(): PrintUtils.print_warn( - "未输入订阅地址,可稍后在客户端导入,推荐: {}".format( - self.RECOMMEND_SUBSCRIBE_URL - ) + "未输入订阅地址,可稍后在客户端导入,推荐: {}".format(self._recommend_url()) ) + else: + PrintUtils.print_warn("未输入订阅地址,可稍后在客户端导入") user_homes = FileUtils.getusershome() if len(user_homes) > 0: - self._setup_proxy_alias(user_homes[0]) + self._setup_proxy_alias(user_homes[0], client["port"]) else: PrintUtils.print_warn("未找到用户目录,跳过 proxy_add 快捷命令写入") - self._print_launch_tips(mode) + self._print_launch_tips(client) return True def run(self): - self.install_proxy_tool() + return self.install_proxy_tool() diff --git a/tools/tool_install_qq.py b/tools/tool_install_qq.py index 659a544..f260a3f 100644 --- a/tools/tool_install_qq.py +++ b/tools/tool_install_qq.py @@ -1,34 +1,61 @@ # -*- coding: utf-8 -*- +import glob +import os from .base import BaseTool from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask from .base import osversion, osarch from .base import run_tool_file +# QQ 官网的下载地址是带 sign 和时间戳的临时链接, 只有从官网页面点下载时才会生成, 过期就 403, +# 脚本里写死的地址(原来的 linuxqq_3.2.1)早已失效. 所以改成: 用户在官网下载 deb, 脚本负责找到并安装 +qq_download_page = "https://im.qq.com/linuxqq/index.shtml" +qq_deb_patterns = ["QQ_*.deb", "linuxqq_*.deb"] +download_dirs = ["下载", "Downloads"] + + class Tool(BaseTool): def __init__(self): self.type = BaseTool.TYPE_INSTALL self.name = "一键安装QQ" self.author = '五柳小生' + def find_qq_deb(self): + """在各用户的 ~/下载 和 ~/Downloads 里找 QQ 安装包, 返回最新且架构匹配的一个""" + debs = [] + for home in FileUtils.getusershome(): + for folder in download_dirs: + for pattern in qq_deb_patterns: + debs += glob.glob(os.path.join(home, folder, pattern)) + debs.sort(key=os.path.getmtime, reverse=True) + for deb in debs: + result = CmdTask('dpkg-deb -f "{}" Architecture'.format(deb)).run() + if result[0] != 0 or len(result[1]) == 0: + PrintUtils.print_warn("{} 不是完整的安装包(可能没下载完), 跳过".format(deb)) + continue + arch = result[1][0].strip() + # 龙芯的包写 loongarch64, dpkg 报的是 loong64 + if arch.replace("loongarch64", "loong64") != osarch: + PrintUtils.print_warn("{} 是{}版本, 和当前系统({})不符, 跳过".format(deb, arch, osarch)) + continue + return deb + return None + def install_qq(self): - PrintUtils.print_info("开始根据系统架构,为你下载对应版本的QQ~") - if osarch == 'arm64': - CmdTask('sudo wget https://dldir1.qq.com/qqfile/qq/QQNT/b69de82d/linuxqq_3.2.1-17153_arm64.deb -O /tmp/qq.deb',os_command=True).run() - elif osarch == 'amd64': - CmdTask('sudo wget https://dldir1.qq.com/qqfile/qq/QQNT/b69de82d/linuxqq_3.2.1-17153_amd64.deb -O /tmp/qq.deb',os_command=True).run() - else: + deb = self.find_qq_deb() + if deb is None: + PrintUtils.print_warn("没有在「下载」目录里找到QQ安装包") + PrintUtils.print_info("请先打开 {} 下载 deb 版(当前系统架构: {}), 下完再运行一次".format(qq_download_page, osarch)) return False - PrintUtils.print_info("下载完成,接下来为您安装QQ") - # 安装依赖 - CmdTask("sudo apt-get update") - CmdTask("sudo apt install libgtk2.0-0") - # 解压安装包 - CmdTask("sudo dpkg -i /tmp/qq.deb").run() - CmdTask("rm -rf /tmp/qq.deb").run() - PrintUtils.print_info("安装完成~") - - + pkg = CmdTask('dpkg-deb -f "{}" Package'.format(deb)).run()[1][0].strip() + PrintUtils.print_info("找到QQ安装包: {}, 开始安装~".format(deb)) + # 用 apt 装本地包, 缺的依赖会一起装上 + CmdTask('sudo DEBIAN_FRONTEND=noninteractive apt install -y "{}"'.format(deb), os_command=True).run() + if "install ok installed" not in " ".join(CmdTask("dpkg-query -W -f='${Status}' " + pkg).run()[1]): + PrintUtils.print_error("QQ安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("QQ安装成功~") + return True def run(self): #正式的运行 - self.install_qq() \ No newline at end of file + return self.install_qq() diff --git a/tools/tool_install_ros.py b/tools/tool_install_ros.py index 1d78e14..c78bafc 100644 --- a/tools/tool_install_ros.py +++ b/tools/tool_install_ros.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- -from pickle import NONE +import os +import re from .base import BaseTool -from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask +from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask, AdUtils, StatUtils from .base import osversion from .base import run_tool_file from .base import tr @@ -194,6 +195,11 @@ def get_desktop_version(name): "https://gitee.com/fishros/rosdistro/raw/master/ros.asc", "https://raw.githubusercontent.com/ros/rosdistro/master/ros.asc", ] +# ROS 官方 GPG 公钥完整指纹, 密钥服务器兜底时使用(短指纹容易撞) +ros_key_fingerprint = "C1CF6E31E6BADE8868B172B4F42ED6FBAB17C654" +ros_keyring = "/etc/apt/trusted.gpg.d/ros.gpg" +# 没有 gpg 时的退路: apt>=1.4(Ubuntu 18.04+) 能直接识别 trusted.gpg.d 下 ASCII 格式的 .asc 公钥 +ros_keyring_asc = "/etc/apt/trusted.gpg.d/ros.asc" ros_mirror_dic = { "tsinghua": { @@ -212,6 +218,10 @@ def get_desktop_version(name): "ROS1": "https://repo.huaweicloud.com/ros/ubuntu/", "ROS2": "https://repo.huaweicloud.com/ros2/ubuntu/", }, + # 阿里云的 ROS1 源停在 2021 年, 不要用; ROS2 源与官方同步, 是 EOL 系统(focal/bionic...)唯一可用的国内源 + "aliyun": { + "ROS2": "https://mirrors.aliyun.com/ros2/ubuntu/", + }, "packages.ros": { "ROS1": "http://packages.ros.org/ros/ubuntu/", "ROS2": "http://packages.ros.org/ros2/ubuntu/", @@ -380,11 +390,12 @@ def get_desktop_version(name): } +# 2026-10 实测: 清华/中科大/华为/MirrorZ 的 ROS2 源里 bionic/focal/buster/xenial 的索引 +# 停在 2024~2025 年, 但对应的 deb 包已被删除, apt 能看到 ros-foxy-* 却下载 404; +# 阿里云与官方源同步正常. 这几个系统的 ROS2 只保留阿里云和官方源. ros2_dist_dic = { "bionic": { - "tsinghua", - "mirrorz", - "huawei", + "aliyun", "packages.ros", }, "bookworm": { @@ -392,6 +403,7 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "bullseye": { @@ -399,12 +411,11 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "buster": { - "tsinghua", - "ustc", - "huawei", + "aliyun", "packages.ros", }, "cosmic": { @@ -417,10 +428,7 @@ def get_desktop_version(name): "packages.ros", }, "focal": { - "tsinghua", - "ustc", - "huawei", - "mirrorz", + "aliyun", "packages.ros", }, "jessie": { @@ -434,6 +442,7 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "noble": { @@ -441,9 +450,15 @@ def get_desktop_version(name): "ustc", "huawei", "mirrorz", + "aliyun", "packages.ros", }, "resolute": { + "tsinghua", + "ustc", + "huawei", + "mirrorz", + "aliyun", "packages.ros", }, "stretch": { @@ -466,10 +481,7 @@ def get_desktop_version(name): "packages.ros", }, "xenial": { - "tsinghua", - "ustc", - "huawei", - "mirrorz", + "aliyun", "packages.ros", }, "utopic": { @@ -490,175 +502,295 @@ def __init__(self): self.type = BaseTool.TYPE_INSTALL self.author = "小鱼" - def get_mirror_by_code(self, code, arch="amd64", first_choose="tsinghua"): + def get_mirror_by_code(self, code, arch="amd64", first_choose="tsinghua", excluded=None): """ 获取镜像通过系统版本号 + excluded: {"ROS1": set(), "ROS2": set()} 已确认不可用的镜像, 跳过 """ + excluded = excluded or {} ros1_choose_queue = [first_choose, "tsinghua", "ustc", "huawei", "packages.ros"] - ros2_choose_queue = [first_choose, "tsinghua", "ustc", "huawei", "packages.ros"] + ros2_choose_queue = [ + first_choose, + "tsinghua", + "ustc", + "huawei", + "aliyun", + "packages.ros", + ] # armhf架构,优先使用官方源 if arch == "armhf": - ros2_choose_queue = ["packages.ros", "tsinghua", "ustc", "huawei"] + ros2_choose_queue = ["packages.ros", "tsinghua", "ustc", "huawei", "aliyun"] mirror = [] - # 确认源里有对应的系统的,比如jammy - if code in ros_dist_dic.keys(): - for item in ros1_choose_queue: - if item in ros_dist_dic[code]: - mirror.append(ros_mirror_dic[item]["ROS1"]) - break - # 确认源里有对应的系统的,比如jammy - if code in ros2_dist_dic.keys(): - for item in ros2_choose_queue: - if item in ros2_dist_dic[code]: - mirror.append(ros_mirror_dic[item]["ROS2"]) + for version, dist_dic, queue in ( + ("ROS1", ros_dist_dic, ros1_choose_queue), + ("ROS2", ros2_dist_dic, ros2_choose_queue), + ): + # 确认源里有对应的系统的,比如jammy + if code not in dist_dic.keys(): + continue + for item in queue: + if item in excluded.get(version, ()): + continue + if item in dist_dic[code] and version in ros_mirror_dic[item]: + mirror.append(ros_mirror_dic[item][version]) break - # if code in ['focal']: - # mirror.append(ros_mirror_dic['packages.ros']['ROS2']) return mirror + def supported_mirrors(self): + """当前系统可选的国内 ROS 镜像(按推荐顺序)""" + codename = osversion.get_codename() + return [ + mirror + for mirror in ("ustc", "tsinghua", "huawei", "mirrorz", "aliyun") + if mirror in ros_dist_dic.get(codename, ()) or mirror in ros2_dist_dic.get(codename, ()) + ] + def select_mirror(self): """ - 让用户选择镜像源 + 让用户选择镜像源, 选 [0] 返回 BaseTool.BACK """ - # 检查当前系统是否支持中科大镜像 - codename = osversion.get_codename() - supported_mirrors = [] + mirror_names = { + "ustc": "中科大镜像源 (推荐国内用户使用)", + "tsinghua": "清华镜像源 (容易被封禁)", + "huawei": "华为镜像源", + "mirrorz": "MirrorZ 校园网联合镜像站", + "aliyun": "阿里云镜像源 (仅ROS2, Ubuntu20.04/18.04 装ROS2请选它)", + } + supported_mirrors = self.supported_mirrors() + + # 只有官方源或只有一个国内源时不再询问 + if len(supported_mirrors) <= 1: + default = supported_mirrors[0] if supported_mirrors else "packages.ros" + PrintUtils.print_info("您的系统默认使用镜像源: {}".format(default)) + return default + + mirror_dict = {} + for count, mirror in enumerate(supported_mirrors, 1): + mirror_dict[count] = mirror_names[mirror] + official = len(supported_mirrors) + 1 + mirror_dict[official] = "ROS官方源 (国外用户或需要最新版本时使用)" - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "ustc" in ros_dist_dic.get(codename, []) or "ustc" in ros2_dist_dic.get( - codename, [] - ): - supported_mirrors.append("ustc") - - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "tsinghua" in ros_dist_dic.get( - codename, [] - ) or "tsinghua" in ros2_dist_dic.get(codename, []): - supported_mirrors.append("tsinghua") - - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "huawei" in ros_dist_dic.get( - codename, [] - ) or "huawei" in ros2_dist_dic.get(codename, []): - supported_mirrors.append("huawei") - - # 添加 mirrorz 镜像源支持 - if codename in ros_dist_dic.keys() or codename in ros2_dist_dic.keys(): - if "mirrorz" in ros_dist_dic.get( - codename, [] - ) or "mirrorz" in ros2_dist_dic.get(codename, []): - supported_mirrors.append("mirrorz") - - # 如果系统支持多个镜像源,则让用户选择 - if len(supported_mirrors) > 1: - mirror_dict = {} - count = 1 - for mirror in supported_mirrors: - if mirror == "ustc": - mirror_dict[count] = "中科大镜像源 (推荐国内用户使用)" - elif mirror == "tsinghua": - mirror_dict[count] = "清华镜像源 (容易被封禁)" - elif mirror == "huawei": - mirror_dict[count] = "华为镜像源" - elif mirror == "mirrorz": - mirror_dict[count] = "中山大学开源软件镜像站 (试运行)" - count += 1 - - mirror_dict[count] = "ROS官方源 (国外用户或需要最新版本时使用)" - - code, result = ChooseTask( - mirror_dict, - "检测到您的系统支持多个ROS镜像源,请选择您想要使用的ROS镜像源(默认清华):", - ).run() - if code == 0: - return "tsinghua" # 默认返回清华源 - elif code == count: - return "packages.ros" # 官方源 - else: - # 根据选择返回对应的镜像源 - for key, value in mirror_dict.items(): - if key == code: - if "中科大" in value: - return "ustc" - elif "清华" in value: - return "tsinghua" - elif "华为" in value: - return "huawei" - elif "中山大" in value: - return "mirrorz" + code, result = ChooseTask( + mirror_dict, + "检测到您的系统支持多个ROS镜像源,请选择您想要使用的ROS镜像源:", + ).run() + if code == 0: + return BaseTool.BACK + if code == official: + return "packages.ros" + return supported_mirrors[code - 1] - else: - # 系统只支持默认的清华源 - PrintUtils.print_info("您的系统默认使用清华镜像源") - return "tsinghua" + def write_ros_source(self, arch, codename, mirrors): + source_data = "" + for mirror in mirrors: + source_data += "deb [arch={}] {} {} main\n".format(arch, mirror, codename) + FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") + FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) + self.ros_mirrors = mirrors + + @staticmethod + def has_command(name): + return CmdTask("command -v {}".format(name), 5).run()[0] == 0 - return "tsinghua" + def http_status(self, url): + """ + 返回 url 跟随跳转后的 HTTP 状态码, 取不到返回空串. + Ubuntu 22.04/24.04 桌面版默认没装 curl, 系统源坏了又装不上时用 wget 兜底. + 带上 apt 的 User-Agent: 中科大镜像对 wget 默认 UA 一律返回 403, 会被误判成包已删除 + """ + user_agent = "Debian APT-HTTP/1.3" + if self.has_command("curl"): + check = CmdTask( + "curl -sIL -A '{}' -o /dev/null -w '%{{http_code}}' --max-time 15 '{}'".format(user_agent, url), 20 + ).run() + return "".join(check[1]).strip() + check = CmdTask( + "wget --spider -S -U '{}' --timeout=15 --tries=1 '{}' 2>&1 | grep 'HTTP/'".format(user_agent, url), 20 + ).run() + codes = re.findall(r"HTTP/[\d.]+ (\d+)", " ".join(check[1])) + return codes[-1] if codes else "" - def add_source(self): + def find_stale_mirrors(self, ros_pkg): + """ + 国内镜像有时只剩索引、包文件已被删除, apt update 正常, 一安装就满屏 404. + 这里对每个 ROS 版本抽一个 ros-base 包发 HEAD 请求, 确认真的能下载. + 返回 {"ROS1"/"ROS2": 镜像名}, 只包含确认 404 的镜像 + """ + stale = {} + for distro_name in set(ros_pkg.values()): + # get_all_instsll_ros_pkgs 返回的是 "foxy" 这样的版本名, 换成真实包名 + pkg = "ros-{}-ros-base".format(distro_name) + result = CmdTask( + "apt-get download --print-uris {} 2>/dev/null | head -n 1".format(pkg), 20 + ).run() + uri = re.findall(r"'([^']+\.deb)'", " ".join(result[1])) + if not uri: + continue + uri = uri[0] + http_code = self.http_status(uri) + # 只认明确的 403/404/410, 网络超时(000)等不算, 避免误判 + if http_code not in ("403", "404", "410"): + continue + plain_uri = uri.replace("https://", "http://") + for name, urls in ros_mirror_dic.items(): + for version, url in urls.items(): + if plain_uri.startswith(url.replace("https://", "http://")): + stale[version] = name + PrintUtils.print_warn( + "检测到{}镜像源[{}]索引已过期, 包文件下载返回{}: {}".format( + version, name, http_code, uri + ) + ) + return stale + + def auto_mirror_order(self, codename): + """ + 全自动模式下的镜像尝试顺序: 国外直接用官方源; 国内按测速结果排列国内镜像, 官方源兜底 + """ + names = [ + name + for name in ("ustc", "tsinghua", "huawei", "mirrorz", "aliyun") + if name in ros_dist_dic.get(codename, ()) or name in ros2_dist_dic.get(codename, ()) + ] + if not names or (tr is not None and not tr.isCN()): + return ["packages.ros"] + names + url_to_name = {} + for name in names: + url = ros_mirror_dic[name].get("ROS2") or ros_mirror_dic[name].get("ROS1") + url_to_name[url] = name + PrintUtils.print_info("正在为您测速挑选最快的ROS镜像源...") + ordered = [url_to_name[url] for url in AptUtils.get_fast_url(list(url_to_name.keys()))] + return ordered + ["packages.ros"] + + def add_source(self, auto=False): """ 检查并添加ROS系统源 + auto: 全自动模式, 不询问用户, 按测速顺序自动尝试, 失败自动换下一个 """ arch = AptUtils.getArch() if arch == None: return False + codename = osversion.get_codename() - # 让用户选择镜像源 - selected_mirror = self.select_mirror() - PrintUtils.print_info("您选择的镜像源: {}".format(selected_mirror)) + if auto: + candidates = self.auto_mirror_order(codename) + selected_mirror = candidates[0] + PrintUtils.print_info("自动选择的ROS镜像源: {}".format(selected_mirror)) + else: + # 让用户选择镜像源 + selected_mirror = self.select_mirror() + if selected_mirror == BaseTool.BACK: + return BaseTool.BACK + PrintUtils.print_info("您选择的镜像源: {}".format(selected_mirror)) + excluded = {"ROS1": set(), "ROS2": set()} + + for attempt in range(8): + mirrors = self.get_mirror_by_code( + codename, arch=arch, first_choose=selected_mirror, excluded=excluded + ) + if not mirrors: + break + PrintUtils.print_info("根据您的系统和选择,为您推荐安装源为{}".format(mirrors)) + self.write_ros_source(arch, codename, mirrors) - # add source 1 - mirrors = self.get_mirror_by_code( - osversion.get_codename(), arch=arch, first_choose=selected_mirror + ros_pkg = self.get_all_instsll_ros_pkgs() + if ros_pkg: + stale = self.find_stale_mirrors(ros_pkg) + if not stale: + if auto: + PrintUtils.print_success("ROS软件源添加成功") + else: + PrintUtils.print_success( + "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" + ) + return True + for version, name in stale.items(): + excluded[version].add(name) + PrintUtils.print_warn("自动切换到下一个可用的镜像源重试...") + continue + + # apt update 失败或搜不到 ROS 包, 换一个镜像源再试 + for version in excluded.keys(): + excluded[version].add(selected_mirror) + if auto: + remaining = [m for m in candidates if m not in excluded["ROS2"]] + if not remaining: + break + selected_mirror = remaining[0] + PrintUtils.print_warn("换源后更新失败, 自动换用镜像源: {}".format(selected_mirror)) + continue + PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") + selected_mirror = self.select_mirror() + if selected_mirror == BaseTool.BACK: + return BaseTool.BACK + PrintUtils.print_info("您重新选择的镜像源: {}".format(selected_mirror)) + for version in excluded.keys(): + excluded[version].discard(selected_mirror) + + PrintUtils.print_error( + "多次换源后仍然失败,请携带 /tmp/fishros_install.log 到鱼香社区 https://fishros.org.cn/forum 一键安装板块反馈!" ) - PrintUtils.print_info("根据您的系统和选择,为您推荐安装源为{}".format(mirrors)) - source_data = "" - for mirror in mirrors: - source_data += "deb [arch={}] {} {} main\n".format( - arch, mirror, osversion.get_codename() - ) - FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") - FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) + return False - ros_pkg = self.get_all_instsll_ros_pkgs() - if ros_pkg and len(ros_pkg) > 1: - PrintUtils.print_success( - "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" - ) - return + @staticmethod + def key_sources(): + """ + 公钥下载地址, 按可靠程度排序. 第一个是随一键安装一起发布的公钥(tools/keys/ros.asc): + 用户既然能下载到一键安装, 就一定能从同一台服务器下载到它 + """ + prefix = os.environ.get("FISHROS_URL", "http://mirror.fishros.com/install/") + return [os.path.join(prefix, "tools/keys/ros.asc")] + key_urls - # 如果第一次尝试失败,让用户重新选择镜像源 - PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") - retry_mirror = self.select_mirror() - while retry_mirror != selected_mirror: - PrintUtils.print_info("您重新选择的镜像源: {}".format(retry_mirror)) - mirrors = self.get_mirror_by_code( - osversion.get_codename(), arch=arch, first_choose=retry_mirror - ) - PrintUtils.print_info( - "根据您的系统和选择,为您推荐安装源为{}".format(mirrors) - ) - source_data = "" - for mirror in mirrors: - source_data += "deb [arch={}] {} {} main\n".format( - arch, mirror, osversion.get_codename() - ) - FileUtils.delete("/etc/apt/sources.list.d/ros-fish.list") - FileUtils.new("/etc/apt/sources.list.d/", "ros-fish.list", source_data) - ros_pkg = self.get_all_instsll_ros_pkgs() - if ros_pkg and len(ros_pkg) > 1: - PrintUtils.print_success( - "恭喜,成功添加ROS源,接下来可以使用apt安装ROS或者使用[1]一键安装ROS安装!" - ) - return - else: - PrintUtils.print_warn("换源后更新失败,您可以重新选择镜像源再尝试!") - retry_mirror = self.select_mirror() + @staticmethod + def gpg_cmd(*commands): + """ + 拼出一段 shell: 依次执行若干条 gpg 命令, 都用同一个临时目录当 GNUPGHOME, 结束后删掉. + 全新系统里 root 从没用过 gpg, /root/.gnupg 不存在, gpg 连密钥服务器时起不来 dirmngr + ("缺少 dirmngr"), 导入/校验也会因为建不了锁文件失败 + """ + gpg = 'sudo env GNUPGHOME="$d" gpg --batch ' + body = " && ".join(gpg + c for c in commands) + return "sh -c 'd=$(mktemp -d) && {}; r=$?; rm -rf \"$d\"; exit $r'".format(body) + + def key_fingerprint_ok(self, key_file): + """校验公钥是不是 ROS 官方那把; 没有 gpg 时只能跳过""" + if not self.has_command("gpg"): + return True + result = CmdTask( + self.gpg_cmd("--with-colons --import-options show-only --import {}".format(key_file)), 20 + ).run() + return ros_key_fingerprint in " ".join(result[1]) + + def download_ros_key(self): + """ + 依次尝试各个公钥地址, 每个地址最多试 3 次(网络抖动时 TLS 连接会被重置, curl 的 --retry 不重试这种错误), + 内容是 PGP 公钥且指纹正确才采用, 返回本地文件路径 + """ + key_file = "/tmp/fishros_ros.asc" + # wget 一定有(用户就是用它下载的一键安装), curl 不一定; gitee 会 302 跳转, curl 必须带 -L + if self.has_command("curl"): + download = "curl -fsSL --max-time 30 {url} -o {out}" else: - PrintUtils.print_error( - "您选择了相同的镜像源,四次换源后都失败了,请及时联系小鱼获取解决方案并处理!" - ) + download = "wget -q --timeout=30 -O {out} {url}" + sources = self.key_sources() + PrintUtils.print_success(tr.tr("正在获取ROS公钥:{}").format(sources)) + for key_url in sources: + for attempt in range(3): + CmdTask("rm -f {}".format(key_file), 3).run() + if CmdTask(download.format(url=key_url, out=key_file), 40).run()[0] != 0: + continue + if CmdTask("grep -q 'BEGIN PGP PUBLIC KEY BLOCK' {}".format(key_file), 3).run()[0] != 0: + PrintUtils.print_warn("{} 返回的不是公钥, 尝试下一个地址".format(key_url)) + break + if not self.key_fingerprint_ok(key_file): + PrintUtils.print_warn("{} 的公钥指纹不对, 不予采用".format(key_url)) + break + PrintUtils.print_success(tr.tr("已获取ROS公钥:{}").format(key_url)) + self.key_source = key_url + return key_file + return None def add_key(self): PrintUtils.print_success( @@ -671,75 +803,62 @@ def add_key(self): AptUtils.install_pkg("curl") AptUtils.install_pkg("gnupg2") - # add key - PrintUtils.print_success(tr.tr("正在挑选最快的密钥服务:{}").format(key_urls)) - key_url = AptUtils.get_fast_url(key_urls) - if not key_url: - PrintUtils.print_error(tr.tr("获取密钥失败")) - return - key_url = key_url[0] - PrintUtils.print_success(tr.tr("已自动选择最快密钥服务:{}").format(key_url)) - - has_apt_key = CmdTask("command -v apt-key", 10).run()[0] == 0 - if has_apt_key: - cmd_result = CmdTask( - "curl -s {} | sudo apt-key add -".format(key_url) - ).run() - if cmd_result[0] != 0: + cmd_result = (1, [], []) + key_file = self.download_ros_key() + if key_file: + # 统一导入 trusted.gpg.d, 新旧系统都认, 不依赖已弃用的 apt-key + if self.has_command("gpg"): cmd_result = CmdTask( - "curl -s {} | sudo apt-key add -".format(key_url) + "{} && sudo chmod 644 {}".format( + self.gpg_cmd("--yes --dearmor -o {} {}".format(ros_keyring, key_file)), ros_keyring + ), + 20, ).run() - # 针对近期密钥更新问题 - CmdTask( - "sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F42ED6FBAB17C654", - 10, - ).run() if cmd_result[0] != 0: - PrintUtils.print_info( - tr.tr("导入密钥失败,开始更换导入方式并二次尝试...") - ) + # 没有 gpg(精简系统/容器, 或系统源坏了装不上 gnupg)时直接放 .asc 公钥 + PrintUtils.print_info("gpg 不可用, 改为直接导入 ASCII 格式公钥") cmd_result = CmdTask( - "sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-keys F42ED6FBAB17C654", + "sudo rm -f {0} && sudo cp {1} {2} && sudo chmod 644 {2}".format( + ros_keyring, key_file, ros_keyring_asc + ), 10, ).run() - else: - PrintUtils.print_info( - tr.tr("检测到系统未提供apt-key,切换为gpg keyring导入方式...") - ) - cmd_result = CmdTask( - "curl -fsSL {} | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/ros.gpg".format( - key_url - ) - ).run() - if cmd_result[0] == 0: - CmdTask("sudo chmod 644 /etc/apt/trusted.gpg.d/ros.gpg", 10).run() + if cmd_result[0] != 0 and self.has_command("apt-key"): + cmd_result = CmdTask("sudo apt-key add {}".format(key_file), 20).run() - # 针对trusted.gpg.d问题解决方案 - if FileUtils.check_result(cmd_result, ["trusted.gpg.d"]): - cmd_result = CmdTask( - "curl -s {} | sudo gpg --no-default-keyring --keyring gnupg-ring:/etc/apt/trusted.gpg.d/ros.gpg --import".format( - key_url - ) - ).run() - cmd_result = CmdTask( - "sudo chmod 644 /etc/apt/trusted.gpg.d/ros.gpg", 10 - ).run() + if cmd_result[0] != 0 and self.has_command("gpg"): + PrintUtils.print_info(tr.tr("导入密钥失败,开始更换导入方式并二次尝试...")) + # 从密钥服务器取, 用完整指纹; 有的网络 80 端口不通、有的 11371 端口不通, 两个都试 + for keyserver in ("hkp://keyserver.ubuntu.com:80", "hkp://keyserver.ubuntu.com"): + cmd_result = CmdTask( + "{} | sudo tee {} > /dev/null && sudo chmod 644 {}".format( + self.gpg_cmd( + "--keyserver {} --recv-keys {} >&2".format(keyserver, ros_key_fingerprint), + "--export {}".format(ros_key_fingerprint), + ), + ros_keyring, + ros_keyring, + ), + 60, + ).run() + if cmd_result[0] == 0 and CmdTask("test -s {}".format(ros_keyring), 3).run()[0] == 0: + break + cmd_result = (1, [], []) + if cmd_result[0] != 0: + # 取不到时 tee 会留下空文件, apt 会对它报警告, 删掉 + CmdTask("[ -s {0} ] || sudo rm -f {0}".format(ros_keyring), 3).run() + if cmd_result[0] != 0: + PrintUtils.print_error( + tr.tr("获取密钥失败") + + ", 后续 apt update 可能报 NO_PUBKEY, 请检查网络后重试" + ) return cmd_result def check_sys_source(self): - # 更换系统源 - dic = {1: "更换系统源再继续安装", 2: "不更换继续安装"} - PrintUtils.print_warn( - "=========接下来这一步很很很很重要,如果不知道怎么选请选择1========" - ) - code, result = ChooseTask( - dic, - "新手或首次安装一定要一定要一定要换源并清理三方源,换源!!!系统默认国外源容易失败!!", - ).run() - if code == 1: - tool = run_tool_file("tools.tool_config_system_source", authorun=False) - tool.change_sys_source() + """高手模式第一步: 问要不要换系统源(会先检测当前系统源是国内还是国外再给建议), 选 [0] 返回 BaseTool.BACK""" + tool = run_tool_file("tools.tool_config_system_source", authorun=False) + return tool.ask_change_before_install() def get_all_instsll_ros_pkgs(self): AptUtils.checkapt() @@ -784,13 +903,15 @@ def install_success(self, name): return True return False - def choose_and_install_ros(self): - # search ros packages + def choose_ros_version(self): + """ + 选择 ROS 版本. 返回 (版本名, dic_base); 选 [0] 返回 BaseTool.BACK; 软件源里搜不到 ROS 时返回 None + """ dic_base = AptUtils.search_package( "ros-base", "ros-[A-Za-z]+-ros-base", "ros-", "-base" ) if dic_base == None: - return False + return None ros_name = {} for a in dic_base.keys(): @@ -800,31 +921,37 @@ def choose_and_install_ros(self): ros_name.keys(), "请选择你要安装的ROS版本名称(请注意ROS1和ROS2区别):", True ).run() if code == 0: - PrintUtils.print_error("你选择退出") PrintUtils.print_delay( "是因为没有自己想要的ROS版本吗?ROS版本和操作系统版本是有对应关系的哦,所以可能是你的系统版本{}不对!具体请查看:https://fishros.org.cn/forum/topic/96".format( str(str(osversion.get_name()) + str(osversion.get_version())) - ) + ), + 0.001, ) - return False + return BaseTool.BACK + return ros_name[rosname], dic_base + + def choose_ros_kind(self, version): + """桌面版还是基础版, 返回 1(桌面版) / 2(基础版); 选 [0] 返回 BaseTool.BACK""" + rosname = RosVersions.get_version_string(version) version_dic = {1: rosname + "桌面版", 2: rosname + "基础版(小)"} code, name = ChooseTask( version_dic, "请选择安装的具体版本(如果不知道怎么选,请选1桌面版):", False ).run() + return BaseTool.BACK if code == 0 else code - if code == 0: - PrintUtils.print_error("你选择退出。。。。") - return False - + def install_ros_packages(self, install_version, code, dic_base): + """ + 安装指定版本的ROS, 自定义模式和全自动模式共用 + install_version: 版本名, 如 humble; code: 1 桌面版, 2 基础版; dic_base: 版本名 -> ros-base 包名 + """ install_tool = "aptitude" install_tool_apt = "apt" if osversion.get_version() == "16.04": install_tool = "apt" - install_version = ros_name[rosname] - if install_tool == "aptitude": AptUtils.install_pkg("aptitude") + AdUtils.show("installing") # ======================================================基础版本============================================================== if code == 2: @@ -919,25 +1046,55 @@ def choose_and_install_ros(self): os_command=False, ).run() + # 个别包下载中断(网络抖动/镜像临时不可用)时, 刷新索引后补下一次 + if cmd_result[0] != 0 and FileUtils.check_result( + cmd_result, ["Unable to fetch some archives", "--fix-missing", "无法下载"] + ): + PrintUtils.print_warn("部分软件包下载失败, 刷新软件源后重试一次...") + CmdTask("sudo apt update", 100).run() + target = ( + dic_base[install_version] + if code == 2 + else RosVersions.get_desktop_version(install_version) + ) + cmd_result = CmdTask( + "sudo {} install --fix-missing {} -y".format(install_tool_apt, target), + 300, + os_command=False, + ).run() + # apt broken error if cmd_result[0] != 0: if FileUtils.check_result(cmd_result, ["--fix-broken install"]): CmdTask("sudo apt --fix-broken install -y").run() + # rosname 是 "foxy(ROS2)" 这种展示名, 包名要用 install_version 换算 if code == 2: cmd_result = CmdTask( "sudo {} install {} -y".format( - install_tool, dic_base[rosname] + install_tool_apt, dic_base[install_version] ), 300, os_command=False, ).run() elif code == 1: cmd_result = CmdTask( - "sudo {} install {} -y".format(install_tool, rosname), + "sudo {} install {} -y".format( + install_tool_apt, + RosVersions.get_desktop_version(install_version), + ), 300, os_command=False, ).run() + if cmd_result[0] != 0 and FileUtils.check_result( + cmd_result, [r"404\s+Not Found"] + ): + PrintUtils.print_warn( + "安装过程中出现 404, 说明当前ROS镜像源缺包, 请重新运行一键安装并换一个ROS镜像源(阿里云或ROS官方源)" + ) + + self.last_install_result = cmd_result + # 安装额外的依赖 RosVersions.install_depend(install_version) @@ -949,29 +1106,81 @@ def config_env_and_tip(self, version_name): """ if self.install_success(version_name): run_tool_file("tools.tool_config_rosenv") - PrintUtils.print_success( + PrintUtils.print_highlight( "恭喜你,安装成功了,再附赠你机器人学习宝藏网站:鱼香社区:https://fishros.org.cn/forum" ) else: + AdUtils.mark_failed() PrintUtils.print_error( "安装失败了,请打开鱼香社区:https://fishros.org.cn/forum 在一键安装专区反馈问题..." ) def install_ros(self): + """ + 高手模式: 依次选择 系统源 -> ROS镜像源 -> ROS版本 -> 桌面版/基础版, 然后安装. + 每一步选 [0] 都退回前一步, 在第一步退回时返回 BaseTool.BACK + """ PrintUtils.print_delay("欢迎使用ROS开箱子工具,本工具由[鱼香ROS]小鱼贡献..") if not self.support_install(): return False - self.check_sys_source() - self.add_key() - self.add_source() - - ros_version = self.choose_and_install_ros() - if not ros_version: - return - self.config_env_and_tip(ros_version) - if self.install_success(ros_version): - RosVersions.tip_test_command(ros_version) + AptUtils.wait_and_repair() + step, forward, key_added = 0, True, False + version, dic_base, kind = None, None, None + while step < 4: + if step < 0: + return BaseTool.BACK + if step == 0: + result = self.check_sys_source() + elif step == 1: + # 只有一个可选镜像时这一步不提问, 从下一步退回来时直接再往前退, 否则会卡在两步之间 + if not forward and len(self.supported_mirrors()) <= 1: + step -= 1 + continue + if not key_added: + self.add_key() + key_added = True + result = self.add_source() + if result is False: + return False + elif step == 2: + result = self.choose_ros_version() + if result is None: + return False + if result != BaseTool.BACK: + version, dic_base = result + else: + result = self.choose_ros_kind(version) + kind = result + forward = result != BaseTool.BACK + step += 1 if forward else -1 + + # 统计: 选完开始装算一次「开始安装」, 装完按 install_success 记成功/失败(小白模式里转自定义的也算这里) + StatUtils.report("ros:expert:start") + self.install_ros_packages(version, kind, dic_base) + self.config_env_and_tip(version) + StatUtils.report_ros_result("expert", self.install_success(version)) + if self.install_success(version): + RosVersions.tip_test_command(version) + AdUtils.show("ros_installed") def run(self): - self.install_ros() + # 网上的教程都写"输入 1", 所以小白模式也放在菜单 1 里, 进来先选模式 + while True: + code, _ = ChooseTask( + { + 1: "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么", + 2: "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容", + }, + "请选择安装模式(不知道怎么选就选1):", + ).run() + if code == 0: + return BaseTool.BACK + StatUtils.report("ros:novice:pick" if code == 1 else "ros:expert:pick") + if code == 1: + result = run_tool_file("tools.tool_install_ros_auto", authorun=False).run() + else: + result = self.install_ros() + # 在模式里的第一步选了返回上一步, 回到选模式 + if result != BaseTool.BACK: + return result diff --git a/tools/tool_install_ros1_systemdefault.py b/tools/tool_install_ros1_systemdefault.py index ee7ab47..1fb3e88 100644 --- a/tools/tool_install_ros1_systemdefault.py +++ b/tools/tool_install_ros1_systemdefault.py @@ -12,27 +12,30 @@ def __init__(self): PrintUtils.print_warn("警告!!此工具安装的ROS与从ROS官方软件源安装的ROS冲突!") def run(self): - #正式的运行 - self.check_sys_source() - self.install_system_ros() - return True + # Ubuntu 24.04 起软件源里已经没有系统自带 ROS(ros-base-dev 等), 先查一下, 免得换完源才发现装不了 + if CmdTask("apt-cache show ros-base-dev").run()[0] != 0: + PrintUtils.print_error("当前系统(Ubuntu {})的软件源里没有系统自带ROS(Ubuntu 24.04起已移除)".format(osversion.get_version())) + PrintUtils.print_info("请使用菜单[1]一键安装ROS") + return False + #正式的运行: 每一步选 [0] 都退回前一步 + while True: + if self.check_sys_source() == BaseTool.BACK: + return BaseTool.BACK + result = self.install_system_ros() + if result != BaseTool.BACK: + return result def check_sys_source(self): - # 更换系统源 - dic = {1:"更换系统源再继续安装",2:"不更换继续安装"} - PrintUtils.print_warn("=========接下来这一步很很很很重要,如果不知道怎么选请选择1========") - code,result = ChooseTask(dic, "新手或首次安装一定要一定要一定要换源并清理三方源,换源!!!系统默认国外源容易失败!!").run() - if code==1: - tool = run_tool_file('tools.tool_config_system_source',authorun=False) - tool.change_sys_source() + # 更换系统源: 先检测当前系统源是国内还是国外再给建议, 选 [0] 返回 BaseTool.BACK + tool = run_tool_file('tools.tool_config_system_source',authorun=False) + return tool.ask_change_before_install() def install_system_ros(self): version_dic = {1:"完整版",2:"基础版(小)"} code,name = ChooseTask(version_dic,"请选择安装的具体版本(如果不知道怎么选,请选1桌面版):",False).run() - if code==0: - PrintUtils.print_error("你选择退出。。。。") - return False + if code==0: + return BaseTool.BACK install_tool = 'aptitude' install_tool_apt = 'apt' @@ -76,4 +79,10 @@ def install_system_ros(self): if code==2: cmd_result = CmdTask("sudo {} install {} -y".format(install_tool,"ros-base-dev"),300,os_command=False).run() elif code==1: cmd_result = CmdTask("sudo {} install {} -y".format(install_tool,"ros-desktop-full-dev"),300,os_command=False).run() + # 按实际安装结果提示, 不再一律当成功 + pkg = "ros-base-dev" if code==2 else "ros-desktop-full-dev" + if "install ok installed" not in " ".join(CmdTask("dpkg-query -W -f='${Status}' " + pkg).run()[1]): + PrintUtils.print_error("系统自带ROS安装失败: 如果之前装过ROS官方源的ROS2, 两者会冲突, 请使用菜单[1]一键安装ROS") + return False + PrintUtils.print_success("系统自带ROS安装成功~") return True diff --git a/tools/tool_install_ros_auto.py b/tools/tool_install_ros_auto.py new file mode 100644 index 0000000..1e94fa1 --- /dev/null +++ b/tools/tool_install_ros_auto.py @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +import os +import shutil +from urllib.parse import urlparse +from .base import BaseTool +from .base import PrintUtils, AptUtils, ChooseTask, AdUtils, StatUtils +from .base import osversion +from .base import run_tool_file +from .tool_install_ros import RosVersions + +# 每个系统推荐给新手的 ROS 版本(按优先级). 统一推荐该系统对应的 ROS2 长期支持版, +# 18.04 上的 ROS2 早已停止维护且国内镜像不全, 推荐 ROS1 melodic +recommend_ros = { + "bionic": ["melodic"], + "focal": ["foxy", "noetic"], + "jammy": ["humble"], + "noble": ["jazzy"], + "resolute": ["lyrical"], +} +# 推荐表里没有的系统(如 Debian), 按这个顺序挑第一个能装的 +fallback_ros = ["lyrical", "jazzy", "humble", "foxy", "kilted", "iron", "galactic", "noetic", "melodic", "rolling"] + +# 桌面版装完约占 3~4GB, 剩余空间不足时自动改装基础版 +min_free_gb_for_desktop = 6 +min_free_gb = 2 + + +class Tool(BaseTool): + def __init__(self): + self.name = "全自动安装ROS(新手推荐)" + self.type = BaseTool.TYPE_INSTALL + self.author = "小鱼" + + @staticmethod + def pick_version(codename, available): + for name in recommend_ros.get(codename, []) + fallback_ros: + if name in available: + return name + return sorted(available)[0] if available else None + + @staticmethod + def free_gb(): + try: + return shutil.disk_usage("/").free / 1024.0 / 1024 / 1024 + except Exception: + return 100 + + def system_mirror_failed(self, source_tool): + """最后一次安装的报错里是否有当前系统镜像的下载失败""" + source = getattr(source_tool, "current_source", None) + result = getattr(self.ros_tool, "last_install_result", None) + if not source or not result or len(result) < 3: + return False + host = urlparse(source).netloc + output = " ".join(str(line) for line in list(result[1]) + list(result[2])) + return host in output and ("Failed to fetch" in output or "无法下载" in output) + + def show_plan(self, expect_version, desktop): + ros_name = "适合本系统的ROS版本(添加软件源后自动确定)" + if expect_version: + info = RosVersions.get_version_string(expect_version) + ros_name = "{} {}".format(info, "桌面版" if desktop else "基础版") + PrintUtils.print_success("=" * 60) + PrintUtils.print_success( + "检测到您的系统: {} {} ({}), 架构 {}".format( + osversion.get_name().capitalize(), + osversion.get_version(), + osversion.get_codename(), + AptUtils.getArch(), + ) + ) + PrintUtils.print_info("全自动模式将依次完成以下步骤, 中途不需要再做选择:") + PrintUtils.print_info(" 1. 更换系统软件源: 自动测速, 选最快的镜像(原配置会备份)") + PrintUtils.print_info(" 2. 添加ROS软件源: 自动测速选镜像, 遇到坏掉的镜像自动换下一个") + PrintUtils.print_info(" 3. 安装 {}".format(ros_name)) + PrintUtils.print_info(" 4. 配置环境变量, 新开终端就能直接使用ROS; 工作空间放在中文路径(如 ~/桌面)下也能编译") + if desktop: + PrintUtils.print_info("预计下载 0.5~1GB, 占用磁盘 3~4GB, 用时 10~40 分钟(取决于网速)") + PrintUtils.print_success("=" * 60) + + def print_next_steps(self, version, desktop): + ros1 = RosVersions.get_version(version).version == "ROS1" + PrintUtils.print_success("接下来这样验证ROS是否装好(请先新开一个终端):") + if ros1: + PrintUtils.print_info(" 终端1输入: roscore") + if desktop: + PrintUtils.print_info(" 终端2输入: rosrun turtlesim turtlesim_node (会弹出一只小乌龟)") + PrintUtils.print_info(" 终端3输入: rosrun turtlesim turtle_teleop_key (在这个终端按方向键控制小乌龟)") + elif desktop: + PrintUtils.print_info(" 终端1输入: ros2 run turtlesim turtlesim_node (会弹出一只小乌龟)") + PrintUtils.print_info(" 终端2输入: ros2 run turtlesim turtle_teleop_key (在这个终端按方向键控制小乌龟)") + else: + PrintUtils.print_info(" 输入: ros2 topic list (能列出 /rosout 等话题就说明正常)") + + def run(self): + self.ros_tool = run_tool_file("tools.tool_install_ros", authorun=False) + if not self.ros_tool.support_install(): + return False + + free = self.free_gb() + if free < min_free_gb: + PrintUtils.print_error("磁盘剩余空间只有 {:.1f}GB, 不够安装ROS, 请先清理磁盘或给虚拟机扩容".format(free)) + return False + desktop = free >= min_free_gb_for_desktop + if not desktop: + PrintUtils.print_warn("磁盘剩余空间 {:.1f}GB, 不够装桌面版, 将改为安装基础版(无图形工具)".format(free)) + + codename = osversion.get_codename() + expect = recommend_ros.get(codename, [None])[0] + while True: + self.show_plan(expect, desktop) + code, _ = ChooseTask( + { + 1: "开始全自动安装(推荐)", + 2: "我要自己选(进入高手自定义模式)", + }, + "请确认:", + ).run() + if code == 0: + return BaseTool.BACK + if code == 1: + break + # 高手模式第一步选了返回上一步, 回到这个确认界面 + result = self.ros_tool.install_ros() + if result != BaseTool.BACK: + return result + + # 统计: 确认开始算一次「开始安装」, 之后中途报错退出和装完检测不到都记失败 + StatUtils.report("ros:novice:start") + # 之前跑过一半(自定义模式选到一半退出、Ctrl+C 打断)留下的 apt 锁/未配置完的包先处理掉 + AptUtils.wait_and_repair() + source_tool = run_tool_file("tools.tool_config_system_source", authorun=False) + source_tool.change_sys_source(auto=True) + + self.ros_tool.add_key() + if not self.ros_tool.add_source(auto=True): + StatUtils.report_ros_result("novice", False) + return False + + dic_base = AptUtils.search_package("ros-base", "ros-[A-Za-z]+-ros-base", "ros-", "-base") + if not dic_base: + PrintUtils.print_error("软件源里没有找到可安装的ROS, 请携带 /tmp/fishros_install.log 到鱼香社区反馈") + StatUtils.report_ros_result("novice", False) + return False + version = self.pick_version(codename, list(dic_base.keys())) + if expect and version != expect: + PrintUtils.print_warn("推荐的 {} 当前无法安装, 改为安装 {}".format(expect, version)) + PrintUtils.print_success( + "开始安装 {} {}, 请耐心等待...".format( + RosVersions.get_version_string(version), "桌面版" if desktop else "基础版" + ) + ) + + install_code = 1 if desktop else 2 + self.ros_tool.install_ros_packages(version, install_code, dic_base) + for retry in range(2): + if self.ros_tool.install_success(version) or not self.system_mirror_failed(source_tool): + break + new_source = source_tool.switch_to_next_source() + if not new_source: + break + PrintUtils.print_warn("系统镜像下载中途失败, 已自动换用 {} 继续安装(已下载的包不会重复下载)".format(new_source)) + self.ros_tool.install_ros_packages(version, install_code, dic_base) + self.ros_tool.config_env_and_tip(version) + StatUtils.report_ros_result("novice", self.ros_tool.install_success(version)) + if self.ros_tool.install_success(version): + self.print_next_steps(version, desktop) + self.print_summary(source_tool, expect, version, desktop) + if self.ros_tool.install_success(version): + AdUtils.show("ros_installed") + return True + + def print_summary(self, source_tool, expect, version, desktop): + """小白模式装完后列出帮用户选了什么; 没装成功时也列出来, 方便反馈问题时对照排查""" + ok = self.ros_tool.install_success(version) + PrintUtils.print_success("=" * 60) + if ok: + PrintUtils.print_success("安装完成! 小白模式帮你做了这些选择:") + else: + PrintUtils.print_warn("安装没有成功, 本次自动做的选择如下, 到社区反馈问题时请一起贴上:") + + source = getattr(source_tool, "current_source", None) + if source: + backups = [b for b, original in source_tool.backups if original == "/etc/apt/sources.list"] + if source_tool.use_deb822: + backups.append("/etc/apt/ubuntu.sources.fishros.bak") + PrintUtils.print_info(" 系统软件源: {} {} (测速最快)".format(source_tool.source_names.get(source, ""), source)) + if backups: + PrintUtils.print_info(" 原来的配置已备份到 {}".format(backups[0])) + else: + PrintUtils.print_info(" 系统软件源: 自动更换失败, 保留了原来的系统源") + + mirrors = getattr(self.ros_tool, "ros_mirrors", None) + PrintUtils.print_info(" ROS软件源: {} (测速最快, 且确认包能下载)".format(", ".join(mirrors) if mirrors else "添加失败")) + key = getattr(self.ros_tool, "key_source", None) + PrintUtils.print_info(" ROS公钥: {}".format("来自 {}, 已核对官方指纹".format(key) if key else "已通过备用方式导入")) + + reason = "{} {} 对应的推荐版本".format(osversion.get_name().capitalize(), osversion.get_version()) + if expect and version != expect: + reason = "推荐的 {} 当前无法安装, 改装了它".format(expect) + kind = "桌面版(带小乌龟、RViz 等图形工具)" if desktop else "基础版(磁盘剩余不足 {}GB, 没装图形工具)".format(min_free_gb_for_desktop) + PrintUtils.print_info(" ROS版本: {} {}, {}".format(RosVersions.get_version_string(version), kind, reason)) + + PrintUtils.print_info(" 常用功能包: 没有安装. 做 FishBot 等导航、建图项目时, 重新运行一键安装选菜单 [22] 安装") + PrintUtils.print_info(" (导航 Nav2、建图 slam_toolbox/Cartographer、键盘/手柄遥控等)") + + if ok: + PrintUtils.print_info(" 环境变量: 已写入 ~/.bashrc, 新开终端自动生效") + cmake = "/opt/ros/{}/share/rosidl_adapter/cmake/rosidl_adapt_interfaces.cmake".format(version) + if os.path.exists(cmake): + with open(cmake) as f: + fixed = "idl_tuples ENCODING UTF-8)" in f.read() + PrintUtils.print_info(" 中文路径: {}".format( + "已修复工作空间放在中文路径(如 ~/桌面)下编译消息包报错的问题" if fixed + else "修复没有生效, 工作空间请先放在英文路径下, 或运行菜单 [4] 重试")) + PrintUtils.print_success("=" * 60) diff --git a/tools/tool_install_ros_pkgs.py b/tools/tool_install_ros_pkgs.py new file mode 100644 index 0000000..b1f29ea --- /dev/null +++ b/tools/tool_install_ros_pkgs.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +import glob +import os +from .base import BaseTool +from .base import PrintUtils, CmdTask, FileUtils, AptUtils, ChooseTask, AdUtils +from .tool_install_ros import RosVersions + +# ROS 桌面版不带导航、建图这些功能包, 新手装完 ROS 编译 FishBot 等机器人工作空间时最常缺的就是它们. +# 包名省略 ros-<版本>- 前缀; 安装前会逐个检查软件源里有没有, 没有的跳过(如 noetic 没有 cartographer-ros) +common_pkgs = { + "ROS2": [ + ("导航 Nav2", ["navigation2", "nav2-bringup"]), + ("建图 slam_toolbox", ["slam-toolbox"]), + ("建图 Cartographer", ["cartographer-ros", "cartographer-rviz"]), + ("键盘遥控", ["teleop-twist-keyboard"]), + ("手柄遥控(USB 接收器/有线手柄)", ["joy", "teleop-twist-joy"]), + ("机器人模型 xacro / 关节调试界面", ["xacro", "joint-state-publisher-gui"]), + ("多传感器融合定位", ["robot-localization"]), + ], + "ROS1": [ + ("导航 navigation", ["navigation"]), + ("建图 gmapping", ["slam-gmapping"]), + ("建图 Cartographer", ["cartographer-ros"]), + ("键盘遥控", ["teleop-twist-keyboard"]), + ("手柄遥控(USB 接收器/有线手柄)", ["joy", "teleop-twist-joy"]), + ("机器人模型 xacro / 关节调试界面", ["xacro", "joint-state-publisher-gui"]), + ("多传感器融合定位", ["robot-localization"]), + ], +} +# 与 ROS 版本无关的系统包: 串口通信, 机器人底盘驱动常用 +system_pkgs = [ + ("串口通信 pyserial", ["python3-serial"]), + ("手柄测试工具 jstest", ["joystick"]), +] + + +class Tool(BaseTool): + def __init__(self): + self.name = "一键安装ROS常用功能包(导航/建图)" + self.type = BaseTool.TYPE_INSTALL + self.author = "小鱼" + + @staticmethod + def installed_ros(): + return sorted( + os.path.basename(os.path.dirname(path)) for path in glob.glob("/opt/ros/*/setup.bash") + ) + + @staticmethod + def in_apt_index(pkg): + return CmdTask("apt-cache show {} > /dev/null 2>&1".format(pkg), 10).run()[0] == 0 + + def plan(self, version): + """返回 [(说明, [可装的完整包名])], 软件源里没有的包跳过""" + info = RosVersions.get_version(version) + ros_type = info.version if info else "ROS2" + groups = [ + (label, ["ros-{}-{}".format(version, p) for p in pkgs]) + for label, pkgs in common_pkgs.get(ros_type, common_pkgs["ROS2"]) + ] + system_pkgs + result = [] + for label, pkgs in groups: + available = [p for p in pkgs if self.in_apt_index(p)] + if available: + result.append((label, available)) + else: + PrintUtils.print_info("软件源里没有 {}, 跳过".format(label)) + return result + + def install_common(self, version, auto=False): + """ + 为指定 ROS 版本安装常用功能包. auto=True 时不再询问(全自动模式调用) + 返回是否全部安装成功 + """ + AptUtils.wait_and_repair() + AptUtils.checkapt() + groups = self.plan(version) + if not groups: + PrintUtils.print_error("软件源里找不到 {} 的功能包, 请先用一键安装 [1] 添加 ROS 软件源".format(version)) + return False + PrintUtils.print_success("将为 {} 安装以下常用功能包:".format(RosVersions.get_version_string(version) or version)) + for label, pkgs in groups: + PrintUtils.print_info(" - {}: {}".format(label, " ".join(pkgs))) + if not auto: + code, _ = ChooseTask({1: "安装以上全部(额外下载几百MB)"}, "请确认:").run() + if code != 1: + return False + + pkgs = [p for _, group in groups for p in group] + cmd = "sudo apt install -y {}".format(" ".join(pkgs)) + result = CmdTask(cmd, 0).run() + if result[0] != 0 and FileUtils.check_result(result, ["Unable to fetch", "--fix-missing", "无法下载"]): + PrintUtils.print_warn("部分软件包下载失败, 刷新软件源后重试一次...") + CmdTask("sudo apt update", 100).run() + CmdTask("sudo apt install -y --fix-missing {}".format(" ".join(pkgs)), 0).run() + + failed = [p for p in pkgs if CmdTask("dpkg -s {} > /dev/null 2>&1".format(p), 10).run()[0] != 0] + if failed: + PrintUtils.print_error("以下功能包没有装上: {}".format(" ".join(failed))) + PrintUtils.print_info("可以稍后手动重试: sudo apt install -y {}".format(" ".join(failed))) + return False + PrintUtils.print_success("常用功能包已全部装好, 新开终端即可使用") + self.print_joy_tips(version) + return True + + @staticmethod + def print_joy_tips(version): + info = RosVersions.get_version(version) + ros1 = info is not None and info.version == "ROS1" + PrintUtils.print_info("手柄怎么用: 插上 USB 接收器(虚拟机要在 VMware 菜单「虚拟机 > 可移动设备」里把它连到虚拟机)") + PrintUtils.print_info(" 1. 测试手柄是否被识别: jstest /dev/input/js0 (按键/摇杆时数字会变化)") + if ros1: + PrintUtils.print_info(" 2. 读取手柄数据: rosrun joy joy_node, 另开终端 rostopic echo /joy") + PrintUtils.print_info(" 3. 手柄控制机器人: roslaunch teleop_twist_joy teleop.launch joy_config:=xbox") + else: + PrintUtils.print_info(" 2. 读取手柄数据: ros2 run joy joy_node, 另开终端 ros2 topic echo /joy") + PrintUtils.print_info(" 3. 手柄控制机器人: ros2 launch teleop_twist_joy teleop-launch.py joy_config:=xbox") + PrintUtils.print_info(" (teleop_twist_joy 默认要按住使能键才会发速度, 不同手柄键位可换 joy_config:=ps3 等)") + + def run(self): + versions = self.installed_ros() + if not versions: + PrintUtils.print_error("当前系统还没有安装ROS, 请先使用一键安装 [1] 安装ROS") + return False + version = versions[0] + if len(versions) > 1: + code, version = ChooseTask(versions, "检测到安装了多个ROS, 请选择要给哪个版本安装功能包:", True).run() + if code == 0: + return False + if not self.install_common(version): + AdUtils.mark_failed() + return False + AdUtils.show("pkgs_installed") diff --git a/tools/tool_install_ros_with_docker.py b/tools/tool_install_ros_with_docker.py index d29d9e3..9c88e71 100644 --- a/tools/tool_install_ros_with_docker.py +++ b/tools/tool_install_ros_with_docker.py @@ -1,12 +1,14 @@ # -*- coding: utf-8 -*- from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask,DockerUtils from .base import osversion,osarch from .base import run_tool_file +from .base import tr class RosVersion: STATUS_EOL = 0 STATUS_LTS = 1 + STATUS_ACTIVE = 2 # 还在维护但不是长期支持版(普通版 / rolling) def __init__(self,name,version,status,images=[],arm_images=[]): self.name = name self.version = version @@ -19,20 +21,21 @@ class RosVersions: ros_version = [ RosVersion('lyrical', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:lyrical-desktop'],["osrf/ros:lyrical-desktop"]), RosVersion('jazzy', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:jazzy-desktop-full'],["ros:jazzy"]), - RosVersion('noetic', 'ROS1', RosVersion.STATUS_LTS, ['fishros2/ros:noetic-desktop-full'],["ros:noetic"]), + RosVersion('noetic', 'ROS1', RosVersion.STATUS_EOL, ['fishros2/ros:noetic-desktop-full'],["ros:noetic"]), RosVersion('humble', 'ROS2', RosVersion.STATUS_LTS, ['fishros2/ros:humble-desktop-full'],["ros:humble"]), + RosVersion('kilted', 'ROS2', RosVersion.STATUS_ACTIVE, ['osrf/ros:kilted-desktop-full'],["ros:kilted"]), RosVersion('foxy', 'ROS2', RosVersion.STATUS_EOL, ['fishros2/ros:foxy-desktop'],["ros:foxy"]), - RosVersion('galactic', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:galactic-desktop'],["ros:galactic"]), - RosVersion('iron', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:iron-desktop-full'],["ros:iron"]), - RosVersion('melodic', 'ROS1', RosVersion.STATUS_LTS, ['fishros2/ros:melodic-desktop-full'],["ros:melodic"]), - RosVersion('rolling', 'ROS2', RosVersion.STATUS_LTS, ['osrf/ros:rolling-desktop-full'],["ros:rolling"]), + RosVersion('galactic', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:galactic-desktop'],["ros:galactic"]), + RosVersion('iron', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:iron-desktop-full'],["ros:iron"]), + RosVersion('melodic', 'ROS1', RosVersion.STATUS_EOL, ['fishros2/ros:melodic-desktop-full'],["ros:melodic"]), + RosVersion('rolling', 'ROS2', RosVersion.STATUS_ACTIVE, ['osrf/ros:rolling-desktop-full'],["ros:rolling"]), RosVersion('kinetic', 'ROS1', RosVersion.STATUS_EOL, ['osrf/ros:kinetic-desktop-full'],["ros:kinetic"]), RosVersion('eloquent', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:eloquent-desktop'],["ros:eloquent"]), RosVersion('dashing', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:dashing-desktop'],["ros:dashing"]), RosVersion('crystal', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:crystal-desktop'],["ros:crystal"]), RosVersion('bouncy', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:bouncy-desktop'],["ros:bouncy"]), RosVersion('ardent', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:ardent-desktop'],["ros:ardent"]), - RosVersion('lunar', 'ROS2', RosVersion.STATUS_EOL, ['osrf/ros:lunar-desktop'],["ros:lunar"]), + RosVersion('lunar', 'ROS1', RosVersion.STATUS_EOL, ['osrf/ros:lunar-desktop'],["ros:lunar"]), RosVersion('indigo', 'ROS1', RosVersion.STATUS_EOL, ['osrf/ros:indigo-desktop-full'],["ros:indigo"]) ] @@ -47,6 +50,8 @@ def get_version_string(name): if version.name == ros_version: if version.status==RosVersion.STATUS_EOL: eol = "停止维护" + elif version.status==RosVersion.STATUS_ACTIVE: + eol = "维护中(非长期支持版)" else: eol = "长期支持" return "{}({}),该版本目前状态:{}".format(version.name,version.version,eol), ros_version @@ -124,8 +129,8 @@ def get_container_scripts(self, name, rosversion, delete_file): newgrp docker """.format(name,name,name,name,name,name,name,delete_command,name) if rosversion=="ROS1": - return ros1 - return ros2 + return DockerUtils.permission_check_sh + ros1 + return DockerUtils.permission_check_sh + ros2 def choose_image_version(self): """获取要安装的ROS版本""" @@ -149,9 +154,21 @@ def install_docker(self): def download_image(self,name): """""" PrintUtils.print_success("=================3.下载镜像(该步骤因网络原因会慢一些,若失败请重试)==================") - CmdTask('sudo docker pull {} '.format(RosVersions.get_image(name)),os_command=True).run() - CmdTask('sudo docker pull {} '.format(RosVersions.get_image(name)),os_command=True).run() - CmdTask('sudo docker pull {} '.format(RosVersions.get_image(name)),os_command=True).run() + # 国内直连 Docker Hub 不通, 没配加速地址时自动配上, 不用再先去跑一遍 [17] + if tr.isCN() and not DockerUtils.has_registry_mirror(): + PrintUtils.print_info("检测到Docker还没有配置镜像加速, 自动为你配置华为云加速: {}".format(DockerUtils.registry_mirror)) + DockerUtils.set_registry_mirror(DockerUtils.registry_mirror) + image = RosVersions.get_image(name) + PrintUtils.print_info("镜像{}一般有几个GB, 请确认磁盘空间足够, 耐心等待~".format(image)) + # 华为云加速偶尔会断开连接(EOF), 一层断了整次拉取就失败, 但已下完的层会保留, 重拉会接着下, 最多 5 次 + for i in range(5): + if i > 0: + PrintUtils.print_warn("镜像下载中断(加速服务器偶尔会断开), 第{}次重试, 已下载的部分不会重新下~".format(i)) + CmdTask('sudo docker pull {} '.format(image),os_command=True).run() + if CmdTask('sudo docker image inspect {}'.format(image)).run()[0] == 0: + return True + PrintUtils.print_error("镜像{}下载失败, 请检查网络后重试".format(image)) + return False # create image # TODO 更换好系统源 @@ -253,7 +270,11 @@ def install_ros_with_docker(self): if not rosname: return False self.install_docker() - self.download_image(rosname) + if CmdTask("docker --version").run()[0] != 0: + PrintUtils.print_error("Docker没有装好, 无法继续安装ROS Docker版") + return False + if not self.download_image(rosname): + return False container_name = self.create_container(rosname) self.generte_command(container_name,rosname) self.install_use_tool() @@ -266,4 +287,4 @@ def install_ros_with_docker(self): PrintUtils.print_info("请访问社区的一键安装版块:https://fishros.org.cn/forum/topic/112") def run(self): - self.install_ros_with_docker() + return self.install_ros_with_docker() diff --git a/tools/tool_install_vscode.py b/tools/tool_install_vscode.py index 8b51669..0324a9a 100644 --- a/tools/tool_install_vscode.py +++ b/tools/tool_install_vscode.py @@ -4,6 +4,11 @@ from .base import osversion,osarch from .base import run_tool_file +# 微软官方的「最新稳定版」地址, 会 302 跳到国内也能访问的 CDN(vscode.download.prss.microsoft.com) +# 原来写死 1.86.2(2024-02), 改成每次装最新版 +vscode_latest_url = "https://update.code.visualstudio.com/latest/linux-deb-{}/stable" +vscode_arch = {'amd64': 'x64', 'arm64': 'arm64', 'armhf': 'armhf'} + class Tool(BaseTool): def __init__(self): self.name = "一键安装Vscode" @@ -11,22 +16,27 @@ def __init__(self): self.author = '小鱼' def install_vscode(self): - """ - https://az764295.vo.msecnd.net - """ PrintUtils.print_info("开始根据系统架构,为你下载对应版本的vscode~") # 根据系统架构下载不同版本的安装包 - if osarch=='amd64': - CmdTask('sudo wget https://vscode.download.prss.microsoft.com/dbazure/download/stable/903b1e9d8990623e3d7da1df3d33db3e42d80eda/code_1.86.2-1707854558_amd64.deb -O /tmp/vscode.deb',os_command=True).run() - elif osarch=='arm64': - CmdTask('sudo wget https://vscode.download.prss.microsoft.com/dbazure/download/stable/903b1e9d8990623e3d7da1df3d33db3e42d80eda/code_1.86.2-1707853305_arm64.deb -O /tmp/vscode.deb',os_command=True).run() - else: + if osarch not in vscode_arch: + PrintUtils.print_error("暂不支持当前系统架构:{}".format(osarch)) + return False + CmdTask('sudo wget -T 15 -t 3 {} -O /tmp/vscode.deb'.format(vscode_latest_url.format(vscode_arch[osarch])),os_command=True).run() + if CmdTask("dpkg-deb -I /tmp/vscode.deb").run()[0] != 0: + PrintUtils.print_error("VSCode安装包下载失败, 请检查网络后重试") + CmdTask("rm -rf /tmp/vscode.deb").run() return False PrintUtils.print_info("下载完成,接下来为你安装Vscode~") - CmdTask("sudo dpkg -i /tmp/vscode.deb").run() + # 用 apt 装本地包, 缺依赖时会一起装上(dpkg -i 缺依赖就直接失败) + CmdTask("sudo DEBIAN_FRONTEND=noninteractive apt install -y /tmp/vscode.deb", os_command=True).run() CmdTask("rm -rf /tmp/vscode.deb").run() - PrintUtils.print_info("安装完成~") + result = CmdTask("dpkg-query -W -f='${Status} ${Version}' code").run() + status = " ".join(result[1]) + if result[0] != 0 or "install ok installed" not in status: + PrintUtils.print_error("VSCode安装失败, 请把上面的报错信息发到鱼香社区求助: https://fishros.org.cn/forum") + return False + PrintUtils.print_success("VSCode安装成功, 版本: {}".format(status.replace("install ok installed", "").strip())) + return True def run(self): - self.install_vscode() - + return self.install_vscode() diff --git a/tools/tool_install_wechat.py b/tools/tool_install_wechat.py index d017855..c264f15 100644 --- a/tools/tool_install_wechat.py +++ b/tools/tool_install_wechat.py @@ -1,6 +1,10 @@ # -*- coding: utf-8 -*- +import os +import platform +import re +import shutil from .base import BaseTool -from .base import PrintUtils,CmdTask,FileUtils,AptUtils,ChooseTask +from .base import PrintUtils,CmdTask,FileUtils,ChooseTask,DockerUtils from .base import osversion,osarch from .base import run_tool_file @@ -11,7 +15,7 @@ def __init__(self): self.author = '小鱼' def get_wechat_scripts(self,name,build): - return """xhost +local: >> /dev/null + return DockerUtils.permission_check_sh + """xhost +local: >> /dev/null echo "请输入指令控制{}: 启动(s) 配置:(w) 关闭(c) 重启(r) 删除(d) 重建(b):" read choose case $choose in @@ -27,31 +31,15 @@ def get_wechat_scripts(self,name,build): def install_wechat(self): """ - # 桌面版微信 - https://archive.ubuntukylin.com/software/pool/partner/weixin_2.1.1_amd64.deb - git clone https://gitee.com/ohhuo/wechat_deb.git /tmp/wechat_deb - cd /tmp/wechat_deb && cat wechat_* > wechat.deb - cd /tmp/wechat_deb && sudo dpkg -i wechat.deb - rm -rf /tmp/wechat_deb + # 原来的 [2] 桌面版本(v2.1.1, 优麒麟打包的 weixin) 10-28 下线: 装上会把 /etc/lsb-release 换成麒麟的 + # (直接读这个文件的程序会把系统认成麒麟), 卸载脚本还会出错; 官方 Linux 版早已可用. + # 编号 3、4 不变, 免得网上教程和用户的配置文件对不上 # https://fishros.org.cn/forum/topic/195 - # sudo apt remove wechat-linux-spark - """ - wechat_version_dic = {1:"Docker版本",2:"桌面版本(v2.1.1)",3:"墙裂推荐:官方Linux版本",4:"一键清理"} + wechat_version_dic = {1:"Docker版本",3:"墙裂推荐:官方Linux版本",4:"一键清理微信"} code,_ = ChooseTask(wechat_version_dic,"请选择微信版本(两个版本区别对比:https://fishros.org.cn/forum/topic/195):",False).run() - if code==2: - AptUtils.install_pkg("git") - CmdTask('sudo apt install git',os_command=True).run() - CmdTask('git clone https://gitee.com/ohhuo/wechat_deb.git /tmp/wechat_deb',os_command=True).run() - CmdTask('cd /tmp/wechat_deb && cat wechat_* > wechat.deb',os_command=True).run() - CmdTask('cd /tmp/wechat_deb && sudo dpkg -i wechat.deb',os_command=True).run() - CmdTask('rm -rf /tmp/wechat_deb',os_command=True).run() - PrintUtils.print_success("已为你安装完成wechat~") if code==3: - CmdTask('wget https://dldir1v6.qq.com/weixin/Universal/Linux/WeChatLinux_x86_64.deb -O /tmp/wechat.deb',os_command=True).run() - CmdTask('sudo dpkg -i /tmp/wechat.deb',os_command=True).run() - CmdTask('apt --fix-broken install -y',os_command=True).run() - PrintUtils.print_success("已为你安装完成wechat-linux版本~") + return self.install_official() if code == 1: run_tool_file('tools.tool_install_docker') user = FileUtils.getusers()[0] @@ -89,17 +77,133 @@ def install_wechat(self): PrintUtils.print_info("=================分辨率/目录等配置==============") PrintUtils.print_warn("在任意终端输入wechat,选w-回车进入配置页面") if code==4: - try: - CmdTask("sudo apt remove wechat-linux-spark -y",os_command=True).run() - CmdTask("sudo apt remove wechat-* -y",os_command=True).run() - CmdTask("sudo docker stop wechat",os_command=True).run() - CmdTask("sudo docker rm wechat",os_command=True).run() - CmdTask("sudo docker image rm bestwu/wechat",os_command=True).run() - CmdTask("sudo docker image rm bestwu/wechat",os_command=True).run() - except: - pass + # 清理完回到微信菜单: 常常是清理后接着装官方版, 不装了选 0 回主菜单 + self.clean_wechat() + return self.install_wechat() + + # 一键清理时认得的微信包, 其它名字以 wechat 开头的包按包名显示 + wechat_pkg_names = { + "wechat": "官方 Linux 版微信", + "wechat-beta": "旧版微信 wechat-beta", + "wechat-linux-spark": "星火商店版微信", + } + + def clean_wechat(self): + """ + 一键清理微信: 先查有哪些, 只清理真正装了的(原来不管有没有都跑一遍 apt/docker, 满屏报错也不说清了什么), + 最后列出清理了什么. 主目录里的聊天记录和文件不删 + """ + cleaned = [] + if self.remove_desktop(): + cleaned.append("已下线的桌面版微信 v2.1.1(weixin)") + + result = CmdTask("dpkg-query -W -f='${Package}\\t${Status}\\n' 'wechat*'").run() + pkgs = [line.split("\t")[0] for line in result[1] + if "\t" in line and ("installed" in line or "config-files" in line) and "not-installed" not in line] + if pkgs: + CmdTask("sudo apt purge -y {}".format(" ".join(pkgs)),os_command=True).run() + cleaned += [self.wechat_pkg_names.get(p, p) for p in pkgs if not self.installed(p)] + + if shutil.which("docker"): + if "wechat" in "".join(CmdTask("sudo docker ps -a --filter name=^wechat$ --format '{{.Names}}'").run()[1]): + CmdTask("sudo docker rm -f wechat",os_command=True).run() + cleaned.append("Docker 版微信容器(wechat)") + if CmdTask("sudo docker image inspect bestwu/wechat").run()[0] == 0: + CmdTask("sudo docker image rm bestwu/wechat",os_command=True).run() + cleaned.append("Docker 版微信镜像(bestwu/wechat)") + + PrintUtils.print_info("=" * 50) + if not cleaned: + PrintUtils.print_success("没有发现已安装的微信, 不需要清理") + return True + PrintUtils.print_success("微信清理完成, 共清理了:") + for item in cleaned: + PrintUtils.print_success(" - " + item) + PrintUtils.print_info("聊天记录和收发的文件保存在你的主目录里, 这次没有删除; 确定不要了可以自己手动删掉") + return True + + # 腾讯官方 Linux 微信各架构的安装包, 键是 uname -m 的结果 + # (不用 dpkg --print-architecture: base.getArch 会把 32 位 armhf 也报成 arm64) + official_debs = { + "x86_64": "WeChatLinux_x86_64.deb", + "aarch64": "WeChatLinux_arm64.deb", + "loongarch64": "WeChatLinux_LoongArch.deb", + } + + @staticmethod + def installed(pkg): + status = CmdTask("dpkg-query -W -f='${{Status}}' {}".format(pkg)).run() + return "install ok installed" in "".join(status[1]) + + def remove_desktop(self): + """ + 清理已下线的桌面版 2.1.1(weixin). 它装上时把原来的 /etc/lsb-release 改名成 lsb-release-test、换上麒麟的, + 卸载脚本再挪回去; 原文件不在时卸载脚本报错, 包卡在卸了一半的状态. 所以先保证有 lsb-release-test 再卸, + 卸完要是还是麒麟的(原文件早就丢了), 按 /etc/os-release 重新生成 + """ + if CmdTask("dpkg-query -W weixin").run()[0] != 0: + return False + PrintUtils.print_info("检测到已下线的桌面版微信 v2.1.1, 一并清理并恢复被它改成麒麟的系统版本信息") + # 直接 purge 会把卸载脚本跑两遍(remove、purge 各一次), 第二遍时 lsb-release-test 已被挪走而报错, + # 所以分两步, 每步之前都补上 lsb-release-test + for action in ("remove", "purge"): + if not os.path.exists("/etc/lsb-release-test"): + CmdTask("sudo cp /etc/lsb-release /etc/lsb-release-test").run() + CmdTask("sudo apt {} -y weixin".format(action),os_command=True).run() + with open("/etc/lsb-release") as f: + if "Kylin" not in f.read(): + return True + info = {} + with open("/etc/os-release") as f: + for line in f: + if "=" in line: + key, value = line.strip().split("=", 1) + info[key] = value.strip('"') + with open("/tmp/fishros_lsb_release", "w") as f: + f.write('DISTRIB_ID={}\nDISTRIB_RELEASE={}\nDISTRIB_CODENAME={}\nDISTRIB_DESCRIPTION="{}"\n'.format( + info.get("NAME", "Ubuntu").split()[0], info.get("VERSION_ID", ""), + info.get("VERSION_CODENAME", ""), info.get("PRETTY_NAME", ""))) + CmdTask("sudo cp /tmp/fishros_lsb_release /etc/lsb-release").run() + PrintUtils.print_success("已恢复 /etc/lsb-release: {}".format(info.get("PRETTY_NAME", ""))) + return True + + def install_official(self): + """ + 官方 Linux 版微信. 社区帖 4843: 装过旧版 wechat-beta 时, 新包要覆盖它的 wechat.desktop, dpkg 报错没装上, + 原来不看结果照样提示安装完成. 现在 dpkg 报文件冲突时说明是哪个旧包、问要不要卸载后重装, 最后确认装上了才提示成功 + """ + machine = platform.machine() + deb = self.official_debs.get(machine) + if not deb: + PrintUtils.print_error("官方 Linux 版微信只提供 x86_64、arm64、龙芯(LoongArch) 安装包, 当前系统架构 {} 不支持".format(machine)) + return False + CmdTask('wget -T 15 -t 3 https://dldir1v6.qq.com/weixin/Universal/Linux/{} -O /tmp/wechat.deb'.format(deb),os_command=True).run() + if CmdTask('dpkg-deb -I /tmp/wechat.deb').run()[0] != 0: + PrintUtils.print_error("微信安装包下载不完整, 请检查网络后重试") + return False + + result = CmdTask('sudo dpkg -i /tmp/wechat.deb').run() + if result[0] != 0: + # 中文: 它同时被包含于软件包 wechat-beta 1.0.0.145; 英文: which is also in package wechat-beta 1.0.0.145 + owners = re.findall(r"(?:包含于软件包|also in package)\s+(\S+)", "\n".join(result[1] + result[2])) + old = [p for p in dict.fromkeys(owners) if p != "wechat"] + if old: + PrintUtils.print_warn("系统里已有旧版微信 {}, 和官方新版的文件冲突, 需要先卸载旧版才能安装".format(" ".join(old))) + code,_ = ChooseTask({1:"卸载旧版 {} 后继续安装".format(" ".join(old))},"是否卸载旧版:",False).run() + if code != 1: + return BaseTool.BACK + CmdTask('sudo apt remove -y {}'.format(" ".join(old)),os_command=True).run() + CmdTask('sudo dpkg -i /tmp/wechat.deb',os_command=True).run() + # 缺依赖时 dpkg 会留下未配置完的包, 让 apt 补装依赖并配置好 + CmdTask('sudo apt --fix-broken install -y',os_command=True).run() + + if self.installed("wechat"): + PrintUtils.print_success("已为你安装完成官方 Linux 版微信~ 可在应用列表里找到「微信」") + return True + PrintUtils.print_error("官方 Linux 版微信没有装上, 请把上面的报错发到鱼香社区反馈: https://fishros.org.cn/forum") + return False def run(self): - self.install_wechat() + return self.install_wechat() # sudo docker run -d --name wechat1 --device /dev/snd --ipc="host" -v /tmp/.X11-unix:/tmp/.X11-unix -v $HOME/WeChatFiles:/WeChatFiles -e DISPLAY=unix$DISPLAY -e XMODIFIERS=@im=fcitx -e QT_IM_MODULE=fcitx -e GTK_IM_MODULE=fcitx -e AUDIO_GID=`getent group audio | cut -d: -f3` bestwu/wechat \ No newline at end of file diff --git a/tools/translation/assets/en_US.py b/tools/translation/assets/en_US.py index 82fc3b8..8a069c7 100644 --- a/tools/translation/assets/en_US.py +++ b/tools/translation/assets/en_US.py @@ -1,5 +1,6 @@ translations = { "基础检查通过...": "Base check passed...", + "当前系统:": "Current system:", """===============================================================================\n======欢迎使用一键安装工具,人生苦短,三省吾身,省时省力省心!======= ======一键安装已开源,请放心使用:https://github.com/fishros/install =======\n===============================================================================\n """: """===============================================================================\n====== Welcome to the One-Click Installation Tool, life is short, save your time and energy! =======\n====== One-click installation has been open sourced, please feel free to use: https://github.com/fishros/install =======\n===============================================================================\n""", R""" @@ -21,13 +22,39 @@ "是觉得没有合胃口的菜吗?那快联系的小鱼增加菜单吧~": "Cannot find proper items you want? Please contact fishros to add to the menu~", "---众多工具,等君来用---": "--- A variety of tools just waiting to be used ---", "RUN Choose Task:[请输入括号内的数字]": "RUN Choose Task:[Please enter the number in brackets]", - "ROS相关": "ROS Related", - "AI板块": "AI Section", - "常用软件": "Common Software", - "配置工具": "Configuration Tools", + "ROS安装": "Install ROS", + "ROS开发工具": "ROS Dev Tools", + "换源与网络": "Mirrors & Network", + "开发工具": "Dev Tools", + "日常软件": "Daily Apps", + "一键安装:ROS全自动版(新手推荐,自动识别系统/换源/选版本,只需确认一次)": "Install ROS - Auto (for beginners: detects system, mirrors and version, one confirmation)", + "一键安装(推荐):ROS(小白/高手两种模式,自动识别系统并换源,支持ROS1/ROS2/树莓派/Jetson)": "Install ROS (recommended: beginner/expert modes, detects system and mirrors, ROS1/ROS2, Raspberry Pi/Jetson)", + "请选择安装模式(不知道怎么选就选1):": "Choose install mode (choose 1 if unsure):", + "返回上一步": "Back", + "退出": "Quit", + "已返回主菜单": "Back to the main menu", + "小白模式(推荐): 自动识别系统, 自动换源、选版本, 中途不用再做选择, 装完列出帮你选了什么": "Beginner (recommended): detects system, picks mirrors and version automatically, lists what was chosen at the end", + "高手模式: 自己选择系统源、ROS镜像源、ROS版本和安装内容": "Expert: choose system mirror, ROS mirror, ROS version and packages yourself", + "一键安装:ROS Docker版(支持所有版本ROS/ROS2,系统版本不匹配时使用)": "Install ROS in Docker (any ROS/ROS2 version, when your OS doesn't match)", + "一键安装:系统自带ROS(!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", + "一键配置:ROS环境(快速更新ROS环境设置,自动生成环境选择,修复中文路径编译报错)": "Configure ROS environment (update shell setup, version switch, fix builds under non-ASCII paths)", + "一键安装:ROS常用功能包(导航Nav2/建图slam_toolbox、Cartographer/键盘、手柄遥控等)": "Install common ROS packages (Nav2, slam_toolbox, Cartographer, keyboard/joystick teleop, ...)", + "一键安装:rosdepc(小鱼的国内版rosdep,又快又好用)": "Install rosdepc (rosdep with China mirrors)", + "一键安装:Cartographer(谷歌建图算法,仅支持ROS1:Ubuntu16/18/20)": "Install Cartographer (Google SLAM, ROS1 only: Ubuntu 16/18/20)", + "一键安装:MicroROS开发环境(PlatformIO,支持FishBot)": "Install micro-ROS dev environment (PlatformIO, supports FishBot)", + "一键配置:系统源(更换国内源,支持Ubuntu/Debian全版本)": "Configure system mirrors (Ubuntu/Debian, all versions)", + "一键配置:Python国内源(加速pip安装)": "Configure pip mirror (faster pip install in China)", + "一键配置:Docker代理(支持VPN+代理服务两种模式)": "Configure Docker proxy (VPN or proxy server)", + "一键安装:科学上网代理工具": "Install proxy tool", + "一键安装:VSCode(代码编辑器)": "Install VSCode (code editor)", + "一键安装:Docker": "Install Docker", + "一键安装:Node.js环境": "Install Node.js", + "一键安装:GitHub桌面版(小鱼常用的GitHub客户端)": "Install GitHub Desktop", + "一键安装/卸载:OpenCode(AI编程助手)": "Install/Uninstall OpenCode (AI coding assistant)", + "一键安装:微信(可以在Linux上使用的微信)": "Install WeChat for Linux", + "一键安装:QQ for Linux": "Install QQ for Linux", "请输入[]内的数字以选择:": "Type the number in [] to choose a function:", "欢迎加入机器人学习交流QQ群:438144612(入群口令:一键安装)": "Welcome to join the robot learning exchange QQ group (In Chinese): 438144612 (group password: 一键安装)", - "鱼香小铺正式开业,最低499可入手一台能建图会导航的移动机器人,淘宝搜店:鱼香ROS 或打开链接查看:https://item.taobao.com/item.htm?id=696573635888": "Online shop is now opened! You can get a mobile robot with SLAM and navigation for as low as 499 CNY. Search for '鱼香ROS' on Taobao or visit this link: https://item.taobao.com/item.htm?id=696573635888", """如在使用过程中遇到问题,请打开:https://fishros.org.cn/forum 进行反馈""": """If you encounter any problems, please open: https://fishros.org.cn/forum for feedback""", "请选择你要测试的程序:": "Please select the program you want to test:", "为您从配置文件找到默认选项:": "Found default options from the configuration file:", @@ -42,7 +69,6 @@ "请注意我,检测你在安装过程中出现依赖问题,请在稍后选择解决方案(第一个解决方案不一定可以解决问题,如再遇到可以采用下一个解决方案),即可解决": "PLEASE NOTE that I have detected dependency problems during the installation process. Please select a solution later (the first solution may not solve the problem, please try the next solution if you encounter it again) to resolve it.", "确认了解上述情况,请输入回车继续安装": "Confirm that you understand the above situation, please press Enter to continue the installation", "欢迎使用{},本工具由作者{}提供": "Welcome to use {}, this tool is brought to you by {}", - "一键安装:系统自带ROS (!!警告!!仅供特殊情况下使用)": "Install: System shipped ROS (!!Warning!! Only for special cases)", "已为您切换语言至当前所在国家语言:": "Language switched to the current country's language:':", "检测到当前不在CN,切换服务地址为:https://raw.githubusercontent.com/fishros/install/master/": "Detected that you are not in CN, switching service address to: https://raw.githubusercontent.com/fishros/install/master/", } diff --git a/tools/translation/translator.py b/tools/translation/translator.py index dd89133..b8dd912 100644 --- a/tools/translation/translator.py +++ b/tools/translation/translator.py @@ -9,6 +9,7 @@ import tools.base from tools.base import CmdTask import subprocess +import threading import time _suported_languages = ['zh_CN', 'en_US'] @@ -27,29 +28,29 @@ class Linguist: def __init__(self): # Initialize the current locale. self.country = 'CN' - self._currentLocale = self.getLocalFromIP() - if self._currentLocale is None: - self._currentLocale = locale.getdefaultlocale()[0] - # Load the translation file. - self.lang = self._currentLocale - # Create directory for downloads - CmdTask("mkdir -p /tmp/fishinstall/tools/translation/assets").run() + # 查 IP 所在国家要走一趟外网(约 1s), 放到后台和下载语言文件同时进行 + ip_result = [] + ip_thread = threading.Thread(target=lambda: ip_result.append(self.getLocalFromIP())) + ip_thread.daemon = True + ip_thread.start() if url_prefix: + # 两个语言文件用一条命令下完: 原来 mkdir/wget/mv 拆成 6 条 CmdTask, 刷屏且每条都有额外等待 + # 先下到 /tmp/ 再挪过去, 避免目标目录权限问题; 下载失败就删掉半截文件 + download_cmds = ["mkdir -p /tmp/fishinstall/tools/translation/assets"] for lang in _suported_languages: - # Add timeout and retry mechanism for downloading language files - # Use /tmp/ directory directly to avoid permission issues temp_file = "/tmp/fishros_lang_{}.py".format(lang) final_path = "/tmp/fishinstall/{}".format(lang_url.format(lang).replace(url_prefix, '')) - download_cmd = "wget {} -O {} --no-check-certificate".format(lang_url.format(lang), temp_file) - result = CmdTask(download_cmd).run() - # Move file to final destination if download was successful - if result[0] == 0: - CmdTask("mkdir -p $(dirname {})".format(final_path)).run() - CmdTask("mv {} {}".format(temp_file, final_path)).run() - else: - # Clean up temp file if download failed - CmdTask("rm -f {}".format(temp_file)).run() - + download_cmds.append("(wget -q {} -O {} -T 15 -t 3 --no-check-certificate && mv {} {} || rm -f {})".format( + lang_url.format(lang), temp_file, temp_file, final_path, temp_file)) + CmdTask("; ".join(download_cmds)).run() + ip_thread.join(16) + self._currentLocale = ip_result[0] if ip_result else None + if self._currentLocale is None: + # 查不到 IP 所在国家(断网、DNS 故障、接口不可用)时按系统语言来, 而不是一律英文 + self._currentLocale = self.getLocalFromSystem() + # Load the translation file. + self.lang = self._currentLocale + self.loadTranslationFile() tools.base.tr = self @@ -77,6 +78,21 @@ def tr(self, string) -> str: def isCN(self) -> bool: return self.country == 'CN' + @staticmethod + def getLocalFromSystem() -> str: + """ + 按系统语言选: 英文系统用英文, 其它情况(中文、C/POSIX 等没有语言偏好)用中文(工具本身的语言). + 直接读环境变量: locale.getdefaultlocale() 会把 C.UTF-8 规范化成 en_US, 不能用 + """ + system_locale = "" + for name in ("LC_ALL", "LC_MESSAGES", "LANG"): + if os.environ.get(name): + system_locale = os.environ[name].lower() + break + if system_locale.startswith("en"): + return "en_US" + return "zh_CN" + def getLocalFromIP(self) -> str: local_str = "" temp_file = "/tmp/fishros_check_country.xml" @@ -95,9 +111,9 @@ def getLocalFromIP(self) -> str: else: local_str = "en_US" else: - local_str = "en_US" + local_str = None except Exception: - local_str = "en_US" + local_str = None finally: try: os.remove(temp_file)