diff --git a/.gitignore b/.gitignore index 1de6590..2da0912 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +/.claude +/CLAUDE.md +/AGENTS.md +/logs +/TimiHosts.yaml + # ---> Java # Compiled class file *.class diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..8370726 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..106fade --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..712ab9d --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..dab31a5 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/README.md b/README.md index 03e130d..cbc092a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ -# TimiHost +# TimiHosts -host 管理 \ No newline at end of file +Hosts 管理 + +## 运行 + +1. 先在 `E:\IDEAProject\timi-inject` 执行 `mvn -DskipTests package` 生成本地 jar +2. 在本项目执行 `mvn javafx:run` + +## 说明 + +- 本项目不使用 FXML,所有界面由代码构建 +- 依赖 `E:\IDEAProject\timi-inject\target\timi-inject-0.0.2.jar` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..2628d90 --- /dev/null +++ b/pom.xml @@ -0,0 +1,58 @@ + + + 4.0.0 + + com.imyeyu.hosts + timi-hosts + 0.0.1-SNAPSHOT + + + 21 + 21 + UTF-8 + 21.0.2 + + + + + com.imyeyu.inject + timi-inject + 0.0.2 + + + com.imyeyu.fx.ui + timi-fx-ui + 0.0.2 + + + com.imyeyu.fx.icon + timi-fx-icon + 0.0.2 + + + org.projectlombok + lombok + 1.18.36 + + + ch.qos.logback + logback-classic + 1.5.24 + + + + + + + org.openjfx + javafx-maven-plugin + 0.0.8 + + com.imyeyu.hosts.Launcher + + + + + diff --git a/src/main/java/com/imyeyu/hosts/Launcher.java b/src/main/java/com/imyeyu/hosts/Launcher.java new file mode 100644 index 0000000..d56481d --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/Launcher.java @@ -0,0 +1,41 @@ +package com.imyeyu.hosts; + +import com.imyeyu.config.ConfigLoader; +import com.imyeyu.fx.config.BindingsConfig; +import com.imyeyu.hosts.config.Config; +import com.imyeyu.hosts.ui.FXApplication; +import com.imyeyu.inject.annotation.Bean; +import com.imyeyu.inject.annotation.Configuration; +import com.imyeyu.java.bean.Language; +import com.imyeyu.lang.mapper.ResourcesLanguageMap; +import com.imyeyu.lang.multi.ResourcesMultilingual; +import javafx.application.Application; + +/** + * @author 夜雨 + * @since 2026-01-13 10:40 + */ +@Configuration +public final class Launcher { + + public static void main(String[] args) { + Application.launch(FXApplication.class, args); + } + + @Bean + public Config config() { + ConfigLoader configLoader = new ConfigLoader<>("TimiHosts.yaml", Config.class); + BindingsConfig.addAllFXConverter(configLoader); + return configLoader.load(); + } + + @Bean + public ResourcesMultilingual multilingual() { + ResourcesMultilingual multilingual = new ResourcesMultilingual(); + multilingual.setActivated(Language.Enum.zh_CN); + ResourcesLanguageMap map = new ResourcesLanguageMap(Language.Enum.zh_CN); + map.load("lang/%s.lang"); + multilingual.add(Language.Enum.zh_CN, map); + return multilingual; + } +} diff --git a/src/main/java/com/imyeyu/hosts/config/Config.java b/src/main/java/com/imyeyu/hosts/config/Config.java new file mode 100644 index 0000000..5503782 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/config/Config.java @@ -0,0 +1,15 @@ +package com.imyeyu.hosts.config; + +import com.imyeyu.java.bean.Language; +import javafx.beans.property.ObjectProperty; +import lombok.Data; + +/** + * @author 夜雨 + * @since 2026-01-13 10:53 + */ +@Data +public class Config { + + private ObjectProperty lang; +} diff --git a/src/main/java/com/imyeyu/hosts/model/Host.java b/src/main/java/com/imyeyu/hosts/model/Host.java new file mode 100644 index 0000000..cb7e458 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/model/Host.java @@ -0,0 +1,118 @@ +package com.imyeyu.hosts.model; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +/** + * 映射对象 + * + * @author 夜雨 + * @since 2022-04-25 17:57 + */ +public class Host { + + private final BooleanProperty activated; + private final StringProperty ip; + private final StringProperty host; + private final StringProperty description; + + public Host() { + this.activated = new SimpleBooleanProperty(false); + this.ip = new SimpleStringProperty(""); + this.host = new SimpleStringProperty(""); + this.description = new SimpleStringProperty(""); + } + + /** + * 构造函数 + * + * @param activated 激活状态 + * @param ip IP 地址 + * @param host 域名 + * @param description 备注 + */ + public Host(boolean activated, String ip, String host, String description) { + this.activated = new SimpleBooleanProperty(activated); + this.ip = new SimpleStringProperty(ip); + this.host = new SimpleStringProperty(host); + this.description = new SimpleStringProperty(description); + } + + /** + * 设置激活状态 + * + * @param activated true 为激活 + */ + public void setActivated(boolean activated) { + this.activated.set(activated); + } + + /** @return true 为激活 */ + public boolean isActivated() { + return activated.get(); + } + + /** @return 激活监听 */ + public BooleanProperty activatedProperty() { + return activated; + } + + /** + * 设置 IP + * + * @param ip IP + */ + public void setIP(String ip) { + this.ip.set(ip); + } + + /** @return IP */ + public String getIP() { + return ip.get(); + } + + /** @return IP 监听 */ + public StringProperty ipProperty() { + return ip; + } + + /** + * 设置域名 + * + * @param host 域名 + */ + public void setHost(String host) { + this.host.set(host); + } + + /** @return 域名 */ + public String getHost() { + return host.get(); + } + + /** @return 域名监听 */ + public StringProperty hostProperty() { + return host; + } + + /** + * 设置备注 + * + * @param description 备注 + */ + public void setDescription(String description) { + this.description.set(description); + } + + /** @return 备注 */ + public String getDescription() { + return description.get(); + } + + /** @return 备注监听 */ + public StringProperty descriptionProperty() { + return description; + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/hosts/service/HostService.java b/src/main/java/com/imyeyu/hosts/service/HostService.java new file mode 100644 index 0000000..0ff462b --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/service/HostService.java @@ -0,0 +1,72 @@ +package com.imyeyu.hosts.service; + +import com.imyeyu.hosts.model.Host; +import com.imyeyu.inject.annotation.Service; +import javafx.collections.ObservableList; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +/** + * Hosts 规则服务 + * + * @author Timi + */ +@Slf4j +@Service +public class HostService { + + private final HostStore hostStore; + private final HostsFileReader reader; + private final HostsFileWriter writer; + + /** + * 构造函数注入 + * + * @param hostStore 数据存储 + * @param reader 文件读取器 + * @param writer 文件写入器 + */ + public HostService(HostStore hostStore, HostsFileReader reader, HostsFileWriter writer) { + this.hostStore = hostStore; + this.reader = reader; + this.writer = writer; + } + + /** + * 获取规则列表 + * + * @return 规则列表 + */ + public ObservableList getEntries() { + return hostStore.getEntries(); + } + + /** + * 刷新数据 - 从系统 hosts 文件中重新加载 + * + * @return 加载的 hosts 映射数量 + * @throws IOException 读取文件失败时抛出 + */ + public int refresh() throws IOException { + List hosts = reader.read(); + hostStore.getEntries().setAll(hosts); + log.info("refreshed, loaded {} hosts mapping", hosts.size()); + return hosts.size(); + } + + /** + * 保存数据 - 将当前 hosts 列表保存到系统 hosts 文件 + * + * @return 保存的 hosts 映射数量 + * @throws IOException 写入文件失败时抛出 + */ + public int save() throws IOException { + List hosts = new ArrayList<>(hostStore.getEntries()); + writer.write(hosts); + log.info("saved, wrote {} hosts mapping", hosts.size()); + return hosts.size(); + } +} diff --git a/src/main/java/com/imyeyu/hosts/service/HostStore.java b/src/main/java/com/imyeyu/hosts/service/HostStore.java new file mode 100644 index 0000000..79c794d --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/service/HostStore.java @@ -0,0 +1,30 @@ +package com.imyeyu.hosts.service; + +import com.imyeyu.hosts.model.Host; +import com.imyeyu.hosts.ui.components.HostTable; +import com.imyeyu.inject.annotation.Component; +import com.imyeyu.inject.annotation.Inject; +import com.imyeyu.inject.annotation.PostConstruct; +import javafx.collections.ObservableList; +import lombok.Getter; + +/** + * Hosts 规则数据存储。 + * + * @author 夜雨 + * @since 2026-01-13 16:07 + */ +@Component +public class HostStore { + + @Inject + private HostTable table; + + @Getter + private ObservableList entries; + + @PostConstruct + private void postConstruct() { + entries = table.getItems(); + } +} diff --git a/src/main/java/com/imyeyu/hosts/service/HostsFileReader.java b/src/main/java/com/imyeyu/hosts/service/HostsFileReader.java new file mode 100644 index 0000000..de25bf6 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/service/HostsFileReader.java @@ -0,0 +1,59 @@ +package com.imyeyu.hosts.service; + +import com.imyeyu.hosts.model.Host; +import com.imyeyu.hosts.util.HostsParser; +import com.imyeyu.inject.annotation.Component; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +/** + * Hosts 文件读取器 + * + *

负责从系统 hosts 文件中读取并解析 hosts 映射

+ * + * @author 夜雨 + * @since 2026-01-13 17:20 + */ +@Slf4j +@Component +public class HostsFileReader { + + /** + * 读取并解析系统 hosts 文件 + * + * @return Host 对象列表 + * @throws IOException 文件读取失败时抛出 + */ + public List read() throws IOException { + Path hostsPath = getHostsFilePath(); + log.info("read hosts file: {}", hostsPath); + + List lines = Files.readAllLines(hostsPath, StandardCharsets.UTF_8); + List hosts = HostsParser.parse(lines); + + log.info("read {} hosts mapping", hosts.size()); + return hosts; + } + + /** + * 获取系统 hosts 文件路径 + * + * @return hosts 文件路径 + */ + private Path getHostsFilePath() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + // Windows 系统 + return Paths.get(System.getenv("SystemRoot"), "System32", "drivers", "etc", "hosts"); + } else { + // Linux/Mac 系统 + return Paths.get("/etc/hosts"); + } + } +} diff --git a/src/main/java/com/imyeyu/hosts/service/HostsFileWriter.java b/src/main/java/com/imyeyu/hosts/service/HostsFileWriter.java new file mode 100644 index 0000000..3332eea --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/service/HostsFileWriter.java @@ -0,0 +1,67 @@ +package com.imyeyu.hosts.service; + +import com.imyeyu.hosts.model.Host; +import com.imyeyu.hosts.util.HostsParser; +import com.imyeyu.inject.annotation.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AccessDeniedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Hosts 文件写入器 + * + *

负责将 hosts 映射写入到系统 hosts 文件

+ * + * @author 夜雨 + * @since 2025-01-13 + */ +@Component +public class HostsFileWriter { + + private static final Logger log = LoggerFactory.getLogger(HostsFileWriter.class); + private static final DateTimeFormatter BACKUP_DATE_FORMAT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + /** + * 将 Host 列表写入系统 hosts 文件 + * + * @param hosts Host 对象列表 + * @throws IOException 文件写入失败时抛出 + */ + public void write(List hosts) throws IOException { + Path hostsPath = getHostsFilePath(); + // 格式化为文本行 + List lines = HostsParser.format(hosts); + // 写入文件 + try { + Files.write(hostsPath, lines, StandardCharsets.UTF_8); + log.info("wrote {} hosts mapping", hosts.size()); + } catch (AccessDeniedException e) { + log.error("wrote hosts fail for access denied", e); + throw new IOException("权限不足,请以管理员身份运行本程序", e); + } + } + + /** + * 获取系统 hosts 文件路径 + * + * @return hosts 文件路径 + */ + private Path getHostsFilePath() { + String os = System.getProperty("os.name").toLowerCase(); + if (os.contains("win")) { + // Windows 系统 + return Paths.get(System.getenv("SystemRoot"), "System32", "drivers", "etc", "hosts"); + } else { + // Linux/Mac 系统 + return Paths.get("/etc/hosts"); + } + } +} diff --git a/src/main/java/com/imyeyu/hosts/ui/FXApplication.java b/src/main/java/com/imyeyu/hosts/ui/FXApplication.java new file mode 100644 index 0000000..a5ecf49 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/ui/FXApplication.java @@ -0,0 +1,34 @@ +package com.imyeyu.hosts.ui; + +import com.imyeyu.fx.inject.FXTimiInject; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.hosts.ui.ctrl.Main; +import com.imyeyu.inject.annotation.Inject; +import com.imyeyu.inject.annotation.TimiInjectApplication; +import javafx.application.Application; +import javafx.scene.Scene; +import javafx.stage.Stage; + +/** + * TimiHosts 应用入口。 + * + * @author 夜雨 + * @since 2026-01-13 16:06 + */ +@TimiInjectApplication("com.imyeyu.hosts") +public class FXApplication extends Application implements TimiFXUI { + + @Inject + private Main main; + + @Override + public void start(Stage stage) { + FXTimiInject.run(FXApplication.class, this, stage); + + Scene scene = new Scene(main, 900, 560); + scene.getStylesheets().addAll(CSS_STYLE, CSS_FONT, "style.css"); + stage.setTitle("TimiHosts"); + stage.setScene(scene); + stage.show(); + } +} diff --git a/src/main/java/com/imyeyu/hosts/ui/components/HostTable.java b/src/main/java/com/imyeyu/hosts/ui/components/HostTable.java new file mode 100644 index 0000000..8f3f850 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/ui/components/HostTable.java @@ -0,0 +1,260 @@ +package com.imyeyu.hosts.ui.components; + +import com.imyeyu.fx.icon.TimiFXIcon; +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.ui.components.IconButton; +import com.imyeyu.hosts.model.Host; +import com.imyeyu.inject.annotation.Component; +import com.imyeyu.inject.annotation.PostConstruct; +import com.imyeyu.lang.multi.ResourcesMultilingual; +import javafx.beans.binding.DoubleBinding; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.StringProperty; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.CheckBox; +import javafx.scene.control.TableCell; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.control.TextField; +import javafx.scene.control.cell.PropertyValueFactory; +import javafx.scene.input.ScrollEvent; +import lombok.RequiredArgsConstructor; + +/** + * + * @author 夜雨 + * @since 2022-04-25 18:04 + */ +@Component +@RequiredArgsConstructor +public class HostTable extends TableView implements TimiFXUI { + + private final ResourcesMultilingual multilingual; + + @PostConstruct + private void postConstruct() { + // 选择 + TableColumn activated = new TableColumn<>(multilingual.text("main.activated")); + activated.setCellValueFactory(new PropertyValueFactory<>("activated")); + activated.setPrefWidth(60); + activated.setSortable(false); + activated.setCellFactory(cell -> new TableCell<>() { + + private final CheckBox checkBox; + private BooleanProperty nowBind; // 当前绑定 + + { + checkBox = new CheckBox(); + checkBox.focusedProperty().addListener((obs, o, isFocused) -> { + if (isFocused) { + getSelectionModel().clearAndSelect(getIndex()); + } + }); + setAlignment(Pos.CENTER); + } + + @Override + protected void updateItem(Boolean item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) { + setGraphic(null); + } else { + // 动态绑定 + BooleanProperty thisBind = getTableRow().getItem().activatedProperty(); + if (nowBind == null) { + nowBind = thisBind; + checkBox.selectedProperty().bindBidirectional(nowBind); + } else { + if (nowBind != thisBind) { + checkBox.selectedProperty().unbindBidirectional(nowBind); + nowBind = thisBind; + checkBox.selectedProperty().bindBidirectional(nowBind); + } + } + setGraphic(checkBox); + } + } + }); + getColumns().add(activated); + // IP + TableColumn ip = new TableColumn<>("IP"); + ip.setCellValueFactory(new PropertyValueFactory<>("ip")); + ip.setSortable(false); + ip.setPrefWidth(140); + ip.setCellFactory(cell -> new TableCell<>() { + + private final TextField tf; + private StringProperty nowBind; // 当前绑定 + + { + tf = new TextField(); + tf.getStyleClass().add(CSS.BORDER_N); + tf.focusedProperty().addListener((obs, o, isFocused) -> { + if (isFocused) { + getSelectionModel().clearAndSelect(getIndex()); + } + }); + setPadding(Insets.EMPTY); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) { + setGraphic(null); + } else { + // 动态绑定 + StringProperty thisBind = getTableRow().getItem().ipProperty(); + if (nowBind == null) { + nowBind = thisBind; + tf.textProperty().bindBidirectional(nowBind); + } else { + if (nowBind != thisBind) { + tf.textProperty().unbindBidirectional(nowBind); + nowBind = thisBind; + tf.textProperty().bindBidirectional(nowBind); + } + } + setGraphic(tf); + } + } + }); + getColumns().add(ip); + // 域名列表 + TableColumn hosts = new TableColumn<>(multilingual.text("main.host")); + hosts.setCellValueFactory(new PropertyValueFactory<>("host")); + hosts.setSortable(false); + hosts.setCellFactory(cell -> new TableCell<>() { + + private final TextField tf; + private StringProperty nowBind; // 当前绑定 + + { + tf = new TextField(); + tf.getStyleClass().add(CSS.BORDER_N); + tf.focusedProperty().addListener((obs, o, isFocused) -> { + if (isFocused) { + getSelectionModel().clearAndSelect(getIndex()); + } + }); + setPadding(Insets.EMPTY); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) { + setGraphic(null); + } else { + // 动态绑定 + StringProperty thisBind = getTableRow().getItem().hostProperty(); + if (nowBind == null) { + nowBind = thisBind; + tf.textProperty().bindBidirectional(nowBind); + } else { + if (nowBind != thisBind) { + tf.textProperty().unbindBidirectional(nowBind); + nowBind = thisBind; + tf.textProperty().bindBidirectional(nowBind); + } + } + setGraphic(tf); + } + } + }); + getColumns().add(hosts); + + // 说明 + TableColumn description = new TableColumn<>(multilingual.text("main.description")); + description.setCellValueFactory(new PropertyValueFactory<>("description")); + description.setSortable(false); + description.setPrefWidth(260); + description.setCellFactory(cell -> new TableCell<>() { + + private final TextField tf; + private StringProperty nowBind; // 当前绑定 + + { + tf = new TextField(); + tf.getStyleClass().add(CSS.BORDER_N); + tf.focusedProperty().addListener((obs, o, isFocused) -> { + if (isFocused) { + getSelectionModel().clearAndSelect(getIndex()); + } + }); + setPadding(Insets.EMPTY); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || item == null || getTableRow() == null || getTableRow().getItem() == null) { + setGraphic(null); + } else { + // 动态绑定 + StringProperty thisBind = getTableRow().getItem().descriptionProperty(); + if (nowBind == null) { + nowBind = thisBind; + tf.textProperty().bindBidirectional(nowBind); + } else { + if (nowBind != thisBind) { + tf.textProperty().unbindBidirectional(nowBind); + nowBind = thisBind; + tf.textProperty().bindBidirectional(nowBind); + } + } + setGraphic(tf); + } + } + }); + getColumns().add(description); + + // 删除列 + TableColumn delete = new TableColumn<>(); + delete.setPrefWidth(32); + delete.setSortable(false); + delete.setResizable(false); + delete.setReorderable(false); + delete.setCellFactory(cell -> new TableCell<>() { + + private final IconButton remove; + + { + remove = new IconButton(TimiFXIcon.fromName("FAIL", Colorful.RED)).autoSize(); + remove.setOnAction(e -> getItems().remove(getTableRow().getItem())); + setAlignment(Pos.CENTER); + } + + @Override + protected void updateItem(String item, boolean empty) { + super.updateItem(item, empty); + if (empty || getTableRow() == null || getTableRow().getItem() == null) { + setGraphic(null); + } else { + if (remove.visibleProperty().isBound()) { + remove.visibleProperty().unbind(); + } + remove.visibleProperty().bind(getTableRow().hoverProperty()); + setGraphic(remove); + } + } + }); + getColumns().add(delete); + + DoubleBinding db = widthProperty().subtract(8); + db = db.subtract(activated.widthProperty()); + db = db.subtract(ip.widthProperty()); + db = db.subtract(description.widthProperty()); + db = db.subtract(delete.widthProperty()); + hosts.prefWidthProperty().bind(db); + + getStyleClass().addAll(CSS.BORDER_N, CSS.EDITABLE_TABLE, "host-table"); + + // ---------- 事件 ---------- + + // 滚动时编辑区主动失去焦点 + addEventFilter(ScrollEvent.ANY, e -> requestFocus()); + } +} \ No newline at end of file diff --git a/src/main/java/com/imyeyu/hosts/ui/ctrl/Main.java b/src/main/java/com/imyeyu/hosts/ui/ctrl/Main.java new file mode 100644 index 0000000..f94123e --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/ui/ctrl/Main.java @@ -0,0 +1,90 @@ +package com.imyeyu.hosts.ui.ctrl; + +import com.imyeyu.hosts.config.Config; +import com.imyeyu.hosts.model.Host; +import com.imyeyu.hosts.service.HostService; +import com.imyeyu.hosts.ui.view.MainView; +import com.imyeyu.inject.annotation.Component; +import com.imyeyu.inject.annotation.PostConstruct; +import com.imyeyu.java.bean.Callback; +import javafx.collections.ListChangeListener; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.nio.file.AccessDeniedException; +import java.nio.file.NoSuchFileException; + +/** + * 主控制器 + * + * @author 夜雨 + * @since 2026-01-13 10:41 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class Main extends MainView { + + private final Config config; + + private final HostService hostService; + + /** + * 初始化控制器 + */ + @PostConstruct + private void init() { + add.setOnAction(e -> table.getItems().add(new Host())); + // 绑定保存按钮事件 + save.setOnAction(e -> { + try { + int count = hostService.save(); + labelTips.info("已保存 %d 条映射".formatted(count)); + } catch (AccessDeniedException ex) { + log.error("写入 hosts 失败: 权限不足", ex); + labelTips.error("保存失败: 权限不足,请以管理员身份运行"); + } catch (IOException ex) { + log.error("写入 hosts 失败", ex); + labelTips.error("保存失败: %s".formatted(ex.getMessage())); + } + }); + // 加载初始数据 + try { + int count = hostService.refresh(); + labelTips.info("已加载 %d 条映射".formatted(count)); + } catch (AccessDeniedException e) { + log.error("读取 hosts 失败: 权限不足", e); + labelTips.error("读取失败: 权限不足,请以管理员身份运行"); + } catch (NoSuchFileException e) { + log.error("读取 hosts 失败: 文件不存在", e); + labelTips.error("读取失败: hosts 文件不存在"); + } catch (IOException e) { + log.error("读取 hosts 失败", e); + labelTips.error("读取失败: %s".formatted(e.getMessage())); + } + } + + @PostConstruct + private void label() { + Callback updateStatus = () -> { + int total = table.getItems().size(); + long activated = table.getItems().stream().filter(Host::isActivated).count(); + labelTotal.setText("总数:%s".formatted(total)); + labelActivated.setText("激活:%s".formatted(activated)); + }; + // 监听列表变化 + table.getItems().addListener((ListChangeListener) c -> { + while (c.next()) { + for (Host host : c.getAddedSubList()) { + host.activatedProperty().addListener((obs, o, n) -> updateStatus.handler()); + } + } + updateStatus.handler(); + }); + for (Host host : table.getItems()) { + host.activatedProperty().addListener((obs, o, n) -> updateStatus.handler()); + } + updateStatus.handler(); + } +} diff --git a/src/main/java/com/imyeyu/hosts/ui/view/MainView.java b/src/main/java/com/imyeyu/hosts/ui/view/MainView.java new file mode 100644 index 0000000..d97ef85 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/ui/view/MainView.java @@ -0,0 +1,49 @@ +package com.imyeyu.hosts.ui.view; + +import com.imyeyu.fx.ui.TimiFXUI; +import com.imyeyu.fx.ui.components.LabelTips; +import com.imyeyu.hosts.ui.components.HostTable; +import com.imyeyu.inject.annotation.Inject; +import com.imyeyu.inject.annotation.PostConstruct; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; + +/** + * 主界面。 + * + * @author 夜雨 + * @since 2026-01-13 10:45 + */ +public abstract class MainView extends BorderPane implements TimiFXUI { + + @Inject + protected HostTable table; + + protected final Label labelTotal = new Label(); + protected final Label labelActivated = new Label(); + protected final LabelTips labelTips = new LabelTips(); + protected final Button add = new Button("新增"); + protected final Button save = new Button("保存"); + + @PostConstruct + private void build() { + setCenter(table); + setBottom(new BorderPane() {{ + setBorder(Stroke.TOP); + setPadding(new Insets(4, 6, 4, 6)); + setLeft(new HBox(24) {{ + setAlignment(Pos.CENTER_LEFT); + getChildren().addAll(labelTotal, labelActivated); + }}); + setRight(new HBox(6) {{ + setAlignment(Pos.CENTER_RIGHT); + getChildren().addAll(labelTips, add, save); + }}); + }}); + setBorder(Stroke.TOP); + } +} diff --git a/src/main/java/com/imyeyu/hosts/util/HostsParser.java b/src/main/java/com/imyeyu/hosts/util/HostsParser.java new file mode 100644 index 0000000..0525a52 --- /dev/null +++ b/src/main/java/com/imyeyu/hosts/util/HostsParser.java @@ -0,0 +1,175 @@ +package com.imyeyu.hosts.util; + +import com.imyeyu.hosts.model.Host; +import com.imyeyu.utils.Regex; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * Hosts 文件解析器 + * + *

提供 hosts 文件内容与 Host 模型的双向转换功能

+ * + * @author 夜雨 + * @since 2025-01-13 + */ +@Slf4j +public class HostsParser { + + // IPv4 正则表达式 + private static final Pattern IPV4_PATTERN = Pattern.compile(Regex.IPv4); + + // IPv6 正则表达式(简化版) + private static final Pattern IPV6_PATTERN = Pattern.compile(Regex.IPv6); + + // 域名正则表达式(RFC 1123) + private static final Pattern DOMAIN_PATTERN = Pattern.compile(Regex.DOMAIN); + + /** + * 解析 hosts 文件内容为 Host 对象列表 + * + * @param lines hosts 文件的所有行 + * @return Host 对象列表 + */ + public static List parse(List lines) { + List hosts = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + String line = lines.get(i).trim(); + // 跳过空行 + if (line.isEmpty()) { + continue; + } + // 跳过纯注释行(不包含 IP 和域名) + if (line.startsWith("#")) { + String content = line.substring(1).trim(); + // 如果去掉 # 后还是注释或空行,跳过 + if (!content.contains(" ")) { + continue; + } + } + try { + List parsed = parseLine(line); + hosts.addAll(parsed); + } catch (Exception e) { + log.warn("skip invalid hosts line {}: {}", i + 1, line, e); + } + } + return hosts; + } + + /** + * 解析单行 hosts 配置 + * + * @param line 单行内容 + * @return Host 对象列表(一个 IP 可能对应多个域名) + */ + private static List parseLine(String line) { + List hosts = new ArrayList<>(); + // 检查是否被注释(未激活) + boolean activated = true; + if (line.startsWith("#")) { + activated = false; + line = line.substring(1).trim(); + } + // 提取行尾注释 + String description = ""; + int commentIndex = line.indexOf('#'); + if (commentIndex > 0) { + description = line.substring(commentIndex + 1).trim(); + line = line.substring(0, commentIndex).trim(); + } + // 分割 IP 和域名 + String[] parts = line.split("\\s+"); + if (parts.length < 2) { + return hosts; // 格式不正确,返回空列表 + } + String ip = parts[0]; + // 验证 IP 格式 + if (!isValidIP(ip)) { + log.warn("invalid IP address: {}", ip); + return hosts; + } + // 一个 IP 可能对应多个域名,拆分为多条记录 + for (int i = 1; i < parts.length; i++) { + String host = parts[i]; + // 验证域名格式 + if (!isValidHost(host)) { + log.warn("invalid domain: {}", host); + continue; + } + Host hostObj = new Host(activated, ip, host, description); + hosts.add(hostObj); + } + return hosts; + } + + /** + * 格式化 Host 对象列表为 hosts 文件内容 + * + * @param hosts Host 对象列表 + * @return hosts 文件的文本行 + */ + public static List format(List hosts) { + List lines = new ArrayList<>(); + for (Host host : hosts) { + String line = formatLine(host); + lines.add(line); + } + return lines; + } + + /** + * 格式化单个 Host 对象为 hosts 文件的一行 + * + * @param host Host 对象 + * @return 格式化后的文本行 + */ + private static String formatLine(Host host) { + StringBuilder sb = new StringBuilder(); + // 未激活时添加 # 前缀 + if (!host.isActivated()) { + sb.append("# "); + } + // IP 和域名,使用格式化对齐 + sb.append(String.format("%-20s %s", host.getIP(), host.getHost())); + // 添加备注 + String description = host.getDescription(); + if (description != null && !description.isEmpty()) { + sb.append(" # ").append(description); + } + return sb.toString(); + } + + /** + * 验证 IP 地址格式(支持 IPv4 和 IPv6) + * + * @param ip IP 地址字符串 + * @return 是否为有效的 IP 地址 + */ + private static boolean isValidIP(String ip) { + if (ip == null || ip.isEmpty()) { + return false; + } + return IPV4_PATTERN.matcher(ip).matches() || IPV6_PATTERN.matcher(ip).matches(); + } + + /** + * 验证域名格式(符合 RFC 1123 规范) + * + * @param host 域名字符串 + * @return 是否为有效的域名 + */ + private static boolean isValidHost(String host) { + if (host == null || host.isEmpty()) { + return false; + } + // localhost 是特殊情况 + if ("localhost".equals(host)) { + return true; + } + return DOMAIN_PATTERN.matcher(host).matches(); + } +} diff --git a/src/main/resources/TimiHosts.yaml b/src/main/resources/TimiHosts.yaml new file mode 100644 index 0000000..9f68531 --- /dev/null +++ b/src/main/resources/TimiHosts.yaml @@ -0,0 +1 @@ +lang: zh_cn \ No newline at end of file diff --git a/src/main/resources/lang/zh_CN.lang b/src/main/resources/lang/zh_CN.lang new file mode 100644 index 0000000..1a5b3e5 --- /dev/null +++ b/src/main/resources/lang/zh_CN.lang @@ -0,0 +1,6 @@ +lang=zh_CN + +main.title=Hosts Manager +main.host=地址 +main.activated=激活 +main.description=说明 \ No newline at end of file diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..b0faf08 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,23 @@ + + + + UTF-8 + [%d{HH:mm:ss.SSS}][%-5level][%-30logger{30}] %msg%n + + + + ./logs/debug.log + + UTF-8 + [%d{HH:mm:ss.SSS}][%-5level][%-30logger{30}] %msg%n + + + ./logs/debug/debug.%d{yyyy-MM-dd}.log + 30 + + + + + + + \ No newline at end of file diff --git a/src/main/resources/style.css b/src/main/resources/style.css new file mode 100644 index 0000000..6dfef90 --- /dev/null +++ b/src/main/resources/style.css @@ -0,0 +1,26 @@ +/* 表格 */ +.host-table .table-row-cell:odd { + -fx-background-color: -fx-table-cell-border-color, -fx-control-inner-background; +} +.host-table .table-row-cell:filled:selected, +.host-table .table-row-cell:filled:selected:focused { + -fx-padding: 0; + -fx-text-fill: -fx-selection-bar-text; + -fx-background: #99D1FF; + -fx-border-width: 0; + -fx-background-color: #99D1FF; + -fx-background-insets: 0; + -fx-table-cell-border-color: #99D1FF; +} +.host-table .table-cell { + -fx-padding: 0; + -fx-cell-size: 0; + -fx-border-width: .083333em .083333em .083333em 0; /* 1 1 1 0 */ +} +.host-table .table-row-cell .text-field { + -fx-background-color: -timi-fx-color, #FFF; + -fx-background-insets: 0, 0; +} +.host-table .table-row-cell .text-field:focused { + -fx-background-insets: 0, 1; +} \ No newline at end of file