From a314a1a0d805b1bf083671e341fb2a1f89119493 Mon Sep 17 00:00:00 2001 From: mapleafgo Date: Fri, 11 Jul 2025 17:41:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=88=9D=E5=A7=8B=E5=8C=96?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- init-device.py | 590 +++++++++++++++++++++++++++++++++++++++++++++++++ init_device.md | 13 +- todo.md | 16 +- 3 files changed, 612 insertions(+), 7 deletions(-) create mode 100755 init-device.py diff --git a/init-device.py b/init-device.py new file mode 100755 index 0000000..2f947de --- /dev/null +++ b/init-device.py @@ -0,0 +1,590 @@ +#!/usr/bin/env python3 +""" +设备初始化自动化脚本 +根据 init_device.md 文档自动执行设备初始化步骤 +""" + +import subprocess +import sys +import os +import argparse +import tempfile +import re +from pathlib import Path +from typing import Optional, List +from dataclasses import dataclass +from contextlib import contextmanager + +@dataclass +class InitConfig: + """初始化配置""" + install_chromium: bool = False + username: Optional[str] = None + dry_run: bool = False + +class Logger: + """优雅的日志输出""" + + @staticmethod + def section(title: str): + """输出章节标题""" + print(f"\n{'='*60}") + print(f" {title}") + print(f"{'='*60}") + + @staticmethod + def step(description: str): + """输出步骤说明""" + print(f"\n🔧 {description}") + + @staticmethod + def success(message: str): + """输出成功信息""" + print(f"✅ {message}") + + @staticmethod + def warning(message: str): + """输出警告信息""" + print(f"⚠️ {message}") + + @staticmethod + def error(message: str): + """输出错误信息""" + print(f"❌ {message}") + + @staticmethod + def info(message: str): + """输出信息""" + print(f"ℹ️ {message}") + +class CommandRunner: + """命令执行器""" + + def __init__(self, dry_run: bool = False): + self.dry_run = dry_run + + def _execute_command(self, command: str) -> subprocess.CompletedProcess: + """执行命令的核心逻辑""" + with subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1 + ) as process: + output_lines = [] + + # 实时读取并显示输出 + for line in iter(process.stdout.readline, ''): + print(line.rstrip()) + output_lines.append(line) + + # 等待进程完成 + process.wait() + + return subprocess.CompletedProcess( + command, + process.returncode, + ''.join(output_lines), + "" + ) + + def run(self, command: str, description: str = "", check: bool = True) -> subprocess.CompletedProcess: + """执行命令""" + Logger.step(description or command) + + if self.dry_run: + Logger.info(f"[预览模式] 命令: {command}") + return subprocess.CompletedProcess(command, 0, "", "") + + try: + result = self._execute_command(command) + + if check and result.returncode != 0: + raise subprocess.CalledProcessError(result.returncode, command, result.stdout) + + Logger.success("执行成功") + return result + + except subprocess.CalledProcessError as e: + Logger.error(f"命令执行失败 (退出码: {e.returncode})") + if e.stderr: + print(f"错误详情: {e.stderr.strip()}") + if check: + sys.exit(1) + except Exception as e: + Logger.error(f"执行异常: {e}") + if check: + sys.exit(1) + +@contextmanager +def temp_file_with_content(content: str, suffix: str = ""): + """创建临时文件上下文管理器""" + with tempfile.NamedTemporaryFile(mode='w', suffix=suffix, delete=False) as f: + f.write(content) + temp_path = f.name + + try: + yield temp_path + finally: + Path(temp_path).unlink(missing_ok=True) + +class DeviceInitializer: + """设备初始化器""" + + # 常量定义 + SECTION_OPTIMIZE_BOOT = "优化系统启动时间" + SECTION_UPDATE_SYSTEM = "更新系统包列表" + SECTION_INSTALL_BASIC = "安装基础依赖" + SECTION_ADD_REPOS = "添加第三方软件源" + SECTION_INSTALL_PACKAGES = "安装主要软件包" + SECTION_CONFIGURE_SYSTEM = "配置系统设置" + SECTION_AUTO_STARTX = "配置图形界面自动启动" + SECTION_AUTO_LOGIN = "配置自动登录" + SECTION_CONFIGURE_I3 = "配置 i3 窗口管理器" + SECTION_MANUAL_STEPS = "手动配置步骤" + SECTION_COMPLETE = "初始化完成" + + def __init__(self, config: InitConfig): + self.config = config + # 获取实际用户名(sudo 下运行时从 SUDO_USER 获取) + self.username = config.username or os.getenv('SUDO_USER') or os.getenv('USER') + self.user_home = Path(f"/home/{self.username}") + self.runner = CommandRunner(config.dry_run) + + # 软件包配置 + self.base_packages = [ + "fonts-noto-cjk", + "fonts-noto-color-emoji", + "unclutter", + "xorg", + "i3-wm", + "libvlc-dev", + "vlc", + "libasound2-dev", + "alsa-utils", + "trzsz", + "wireguard", + "wireguard-tools" + ] + + self.repositories = [ + "ppa:xtradeb/apps", + "ppa:trzsz/ppa" + ] + + def _edit_systemd_service(self, service_name: str, override_content: str, description: str): + """兼容地编辑 systemd 服务配置""" + # 直接创建 override 目录和文件,兼容所有 systemctl 版本 + override_dir = f"/etc/systemd/system/{service_name}.d" + override_file = f"{override_dir}/override.conf" + + # 创建目录 + self.runner.run(f"sudo mkdir -p {override_dir}", f"创建服务 override 目录: {override_dir}") + + # 写入配置文件 + with temp_file_with_content(override_content) as temp_file: + self.runner.run(f"sudo cp {temp_file} {override_file}", f"创建服务配置文件: {override_file}") + + # 重新加载 systemd 配置 + self.runner.run("sudo systemctl daemon-reload", "重新加载 systemd 配置") + + Logger.success(f"{description} - 已完成") + + def optimize_boot_time(self): + """优化 Ubuntu 24 开机时间""" + Logger.section(self.SECTION_OPTIMIZE_BOOT) + + override_content = "[Service]\nTimeoutStartSec=2sec\n" + self._edit_systemd_service( + "systemd-networkd-wait-online.service", + override_content, + "配置网络等待服务超时时间为2秒" + ) + + def update_system(self): + """更新系统包""" + Logger.section(self.SECTION_UPDATE_SYSTEM) + self.runner.run("sudo apt-get update", "刷新软件包索引") + + def install_basic_packages(self): + """安装基础包""" + Logger.section(self.SECTION_INSTALL_BASIC) + basic_packages = ["curl", "gpg"] + packages_str = " ".join(basic_packages) + self.runner.run(f"sudo apt-get install -y {packages_str}", f"安装基础包: {', '.join(basic_packages)}") + + def add_repositories(self): + """添加软件源""" + Logger.section(self.SECTION_ADD_REPOS) + + for repo in self.repositories: + self.runner.run(f"sudo add-apt-repository -y {repo}", f"添加软件源: {repo}") + + self.runner.run("sudo apt-get update", "更新软件包索引") + + def install_packages(self): + """安装主要软件包""" + Logger.section(self.SECTION_INSTALL_PACKAGES) + + packages = self.base_packages.copy() + if self.config.install_chromium: + packages.insert(0, "ungoogled-chromium") + Logger.info("已包含 ungoogled-chromium") + + packages_str = " ".join(packages) + package_list = ", ".join(packages) + # 安装软件包,实时显示进度 + self.runner.run(f"sudo apt-get install -y {packages_str}", f"安装软件包: {package_list}") + + def configure_system(self): + """配置系统设置""" + Logger.section(self.SECTION_CONFIGURE_SYSTEM) + + # 设置时区 + self.runner.run("sudo timedatectl set-timezone Asia/Shanghai", "设置时区为上海") + + # 添加用户到相关组 + groups = "audio,video,dialout" + self.runner.run( + f"sudo usermod -aG {groups} {self.username}", + f"将用户 {self.username} 添加到 {groups} 组" + ) + + def setup_auto_startx(self): + """设置自动启动 Xorg""" + Logger.section(self.SECTION_AUTO_STARTX) + + bashrc_path = self.user_home / ".bashrc" + startx_config = """ +# 自动启动 Xorg 和窗口管理器 +if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then + startx +fi +""" + + if self.config.dry_run: + Logger.step("配置自动启动 Xorg") + Logger.info(f"[预览模式] 将添加配置到 {bashrc_path}") + return + + # 检查是否已存在配置 + if bashrc_path.exists(): + try: + content = bashrc_path.read_text(encoding='utf-8') + if 'startx' in content and 'tty1' in content: + Logger.warning("自动启动 Xorg 配置已存在,跳过") + return + except Exception as e: + Logger.warning(f"读取 .bashrc 文件失败: {e}") + + # 添加配置 + try: + with open(bashrc_path, 'a', encoding='utf-8') as f: + f.write(startx_config) + + # 设置文件所有权为实际用户 + self.runner.run(f"sudo chown {self.username}:{self.username} {bashrc_path}", + f"设置 .bashrc 所有权为 {self.username}") + + Logger.success(f"已配置自动启动 Xorg: {bashrc_path}") + except Exception as e: + Logger.error(f"配置自动启动 Xorg 失败: {e}") + + def setup_auto_login(self): + """设置自动登录""" + Logger.section(self.SECTION_AUTO_LOGIN) + + override_content = f"""[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin {self.username} --noclear %I $TERM +""" + + self._edit_systemd_service( + "getty@tty1.service", + override_content, + f"配置用户 {self.username} 自动登录" + ) + + def configure_i3(self): + """配置 i3 窗口管理器""" + Logger.section(self.SECTION_CONFIGURE_I3) + + i3_config_dir = self.user_home / ".config" / "i3" + i3_config_path = i3_config_dir / "config" + + if self.config.dry_run: + Logger.step("配置 i3 窗口管理器") + Logger.info(f"[预览模式] 将配置 i3 配置文件: {i3_config_path}") + return + + # 创建 i3 配置目录 + i3_config_dir.mkdir(parents=True, exist_ok=True) + + # 检查是否已有 i3 配置文件 + if not i3_config_path.exists(): + Logger.info("i3 配置文件不存在,从系统默认配置复制") + self._copy_system_i3_config(i3_config_path) + else: + Logger.info("i3 配置文件已存在,直接更新配置") + + # 无论是新生成还是已存在,都更新配置 + self._update_i3_config(i3_config_path) + + # 设置配置文件所有权为实际用户 + self.runner.run(f"sudo chown -R {self.username}:{self.username} {i3_config_dir}", f"设置 i3 配置目录所有权为 {self.username}") + + Logger.success(f"已配置 i3 窗口管理器: {i3_config_path}") + + def _copy_system_i3_config(self, config_path: Path): + """从系统默认配置复制 i3 配置文件""" + Logger.step("从系统默认配置复制 i3 配置文件") + + system_config = "/etc/i3/config" + self.runner.run(f"cp {system_config} {config_path}", f"复制系统默认 i3 配置到 {config_path}") + + # 移除 i3-config-wizard 指令 + try: + content = config_path.read_text(encoding='utf-8') + if 'i3-config-wizard' in content: + # 移除包含 i3-config-wizard 的行 + lines = content.splitlines() + filtered_lines = [line for line in lines if 'i3-config-wizard' not in line] + + with open(config_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(filtered_lines) + '\n') + + Logger.info("已移除系统配置中的 i3-config-wizard 指令") + + except Exception as e: + Logger.warning(f"移除 i3-config-wizard 指令失败: {e}") + + Logger.success("已从系统默认配置复制 i3 配置文件") + + def _update_i3_config(self, config_path: Path): + """更新现有 i3 配置文件""" + Logger.step("更新现有 i3 配置文件") + + try: + content = config_path.read_text(encoding='utf-8') + except Exception as e: + Logger.error(f"读取 i3 配置文件失败: {e}") + return + + # 添加启动配置项 + startup_configs = [ + "exec --no-startup-id unclutter -root # 隐藏鼠标", + "exec --no-startup-id xset dpms 0 0 0 # 关闭屏幕自动关闭", + "exec --no-startup-id xset s off # 关闭屏幕保护" + ] + + config_modified = False + for config_line in startup_configs: + if config_line not in content: + content += f"\n{config_line}\n" + Logger.info(f"添加配置: {config_line}") + config_modified = True + + # 移除 i3bar 配置块 + if "bar {" in content: + # 使用正则表达式直接移除整个 bar 配置块 + bar_pattern = r'bar\s*\{[^}]*\}' + new_content = re.sub(bar_pattern, '', content, flags=re.DOTALL) + if new_content != content: + content = new_content + Logger.info("已移除 i3bar 状态栏配置") + config_modified = True + + # 只在有修改时才写回文件 + if config_modified: + try: + with open(config_path, 'w', encoding='utf-8') as f: + f.write(content) + Logger.success("i3 配置文件更新完成") + except Exception as e: + Logger.error(f"写入 i3 配置文件失败: {e}") + else: + Logger.info("i3 配置无需修改") + + def show_manual_steps(self): + """显示需要手动完成的步骤""" + Logger.section(self.SECTION_MANUAL_STEPS) + + Logger.info("请手动完成以下配置步骤:") + + print("\n📋 WireGuard 配置:") + print(" 1. 从服务器获取 WireGuard 配置文件") + print(" 2. 保存到 /etc/wireguard/wg0.conf") + print(" 3. 启用并启动 WireGuard:") + print(" sudo systemctl enable wg-quick@wg0") + print(" sudo systemctl start wg-quick@wg0") + + print("\n📋 系统音量配置:") + print(" 1. 运行 alsamixer 进入音量控制界面") + print(" 2. 调节 master 音量到合适级别") + print(" 3. 按 ESC 退出") + print(" 4. 运行 sudo alsactl store 保存音量设置") + + print("\n📋 i3 窗口管理器:") + print(" 1. 重启系统后会自动进入 i3") + print(" 2. 脚本已自动配置必要的设置(隐藏鼠标、禁用屏保等)") + print(" 3. 可以根据需要进一步自定义 ~/.config/i3/config") + + def run_initialization(self): + """运行完整的初始化流程""" + Logger.section("设备初始化开始") + + Logger.info(f"目标用户: {self.username}") + Logger.info(f"用户主目录: {self.user_home}") + Logger.info(f"安装 Chromium: {'是' if self.config.install_chromium else '否'}") + Logger.info(f"预览模式: {'是' if self.config.dry_run else '否'}") + + # 初始化步骤配置 + initialization_steps = [ + ("优化系统启动", self.optimize_boot_time), + ("更新系统包", self.update_system), + ("安装基础依赖", self.install_basic_packages), + ("添加软件源", self.add_repositories), + ("安装主要软件", self.install_packages), + ("配置系统设置", self.configure_system), + ("配置自动启动X", self.setup_auto_startx), + ("配置自动登录", self.setup_auto_login), + ("配置i3窗口管理器", self.configure_i3), + ] + + try: + for step_name, step_func in initialization_steps: + Logger.info(f"正在执行: {step_name}") + step_func() + + self.show_manual_steps() + + Logger.section(self.SECTION_COMPLETE) + Logger.success("🎉 所有自动化步骤已完成") + Logger.warning("⚠️ 请重启系统以使所有更改生效") + Logger.info("💡 重启后请完成 WireGuard 和音量的手动配置") + + except KeyboardInterrupt: + Logger.error("❌ 用户中断了初始化过程") + sys.exit(1) + except Exception as e: + Logger.error(f"❌ 初始化过程中发生错误: {e}") + sys.exit(1) + +class SystemValidator: + """系统环境验证器""" + + @staticmethod + def validate_environment(): + """验证运行环境""" + Logger.section("验证系统环境") + + # 检查是否通过 sudo 运行 + if os.geteuid() != 0: + Logger.error("此脚本需要使用 sudo 运行") + Logger.info("请使用: sudo python3 init-device.py") + sys.exit(1) + + # 检查是否有原始用户信息 + if not os.getenv('SUDO_USER'): + Logger.error("请不要直接以 root 用户运行此脚本") + Logger.info("请使用普通用户通过 sudo 运行: sudo python3 init-device.py") + sys.exit(1) + + Logger.info(f"检测到通过 sudo 运行脚本,原用户: {os.getenv('SUDO_USER')}") + Logger.success("系统环境验证通过") + +def create_argument_parser() -> argparse.ArgumentParser: + """创建命令行参数解析器""" + parser = argparse.ArgumentParser( + description="设备初始化自动化脚本", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +使用示例: + %(prog)s # 基础安装 + %(prog)s --chromium # 包含 Chromium 的完整安装 + %(prog)s --dry-run # 预览模式,不实际执行 + %(prog)s --username user1 # 指定用户名 + """ + ) + + parser.add_argument( + "--chromium", + action="store_true", + help="安装 ungoogled-chromium 浏览器" + ) + parser.add_argument( + "--username", + type=str, + help="指定用户名(默认使用当前用户)" + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="预览模式:仅显示将要执行的操作,不实际执行" + ) + parser.add_argument( + "--version", + action="version", + version="%(prog)s 1.0.0" + ) + + return parser + +def show_preview(config: InitConfig): + """显示预览信息""" + Logger.section("预览模式") + + steps = [ + DeviceInitializer.SECTION_OPTIMIZE_BOOT, + DeviceInitializer.SECTION_UPDATE_SYSTEM, + f"{DeviceInitializer.SECTION_INSTALL_BASIC} (curl, gpg)", + DeviceInitializer.SECTION_ADD_REPOS, + f"{DeviceInitializer.SECTION_INSTALL_PACKAGES}" + (" (包含 ungoogled-chromium)" if config.install_chromium else ""), + f"{DeviceInitializer.SECTION_CONFIGURE_SYSTEM} (时区、用户组)", + DeviceInitializer.SECTION_AUTO_STARTX, + DeviceInitializer.SECTION_AUTO_LOGIN, + DeviceInitializer.SECTION_CONFIGURE_I3, + "显示手动配置步骤" + ] + + Logger.info("将要执行的操作步骤:") + for i, step in enumerate(steps, 1): + print(f" {i:2d}. {step}") + + Logger.warning("这是预览模式,不会实际执行任何操作") + +def main(): + """主函数""" + parser = create_argument_parser() + args = parser.parse_args() + + # 创建配置 + config = InitConfig( + install_chromium=args.chromium, + username=args.username, + dry_run=args.dry_run + ) + + # 预览模式 + if config.dry_run: + show_preview(config) + return + + # 验证环境 + SystemValidator.validate_environment() + + # 运行初始化 + initializer = DeviceInitializer(config) + initializer.run_initialization() + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + Logger.error("用户中断操作") + sys.exit(1) + except Exception as e: + Logger.error(f"程序异常退出: {e}") + sys.exit(1) diff --git a/init_device.md b/init_device.md index a81eed3..1bf2eeb 100644 --- a/init_device.md +++ b/init_device.md @@ -2,7 +2,10 @@ ```bash # 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec -sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service +sudo EDITOR=vim systemctl edit systemd-networkd-wait-online.service +# 在打开的编辑器中添加: +# [Service] +# TimeoutStartSec=2sec ``` ### 初始化设备 @@ -48,10 +51,14 @@ fi ### 自动登录 -编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为: +使用 systemctl edit 修改 getty@tty1 服务: ```bash -ExecStart=-/sbin/agetty --autologin --noclear %I $TERM +sudo EDITOR=vim systemctl edit getty@tty1.service +# 在打开的编辑器中添加: +# [Service] +# ExecStart= +# ExecStart=-/sbin/agetty --autologin --noclear %I $TERM ``` 其中: diff --git a/todo.md b/todo.md index d113eb0..2472af1 100644 --- a/todo.md +++ b/todo.md @@ -4,17 +4,21 @@ ```bash # 在 systemd-networkd-wait-online.service Service 加入 TimeoutStartSec=2sec -sudo vim /etc/systemd/system/network-online.target.wants/systemd-networkd-wait-online.service +sudo EDITOR=vim systemctl edit systemd-networkd-wait-online.service +# 在打开的编辑器中添加: +# [Service] +# TimeoutStartSec=2sec ``` ### 配置时区 + ```bash sudo timedatectl set-timezone Asia/Shanghai ``` ## linux 下播放音频 -```bash +```bash sudo apt install libasound2-dev alsa-utils ``` @@ -107,10 +111,14 @@ fi ### 自动登录 -编辑 `/etc/systemd/system/getty.target.wants/getty@tty1.service` 文件,将 `ExecStart` 行修改为: +使用 systemctl edit 修改 getty@tty1 服务: ```bash -ExecStart=-/sbin/agetty --autologin --noclear %I $TERM +sudo EDITOR=vim systemctl edit getty@tty1.service +# 在打开的编辑器中添加: +# [Service] +# ExecStart= +# ExecStart=-/sbin/agetty --autologin --noclear %I $TERM ``` 其中: