feat: bootstrap TimiHosts desktop app

This commit is contained in:
Timi
2026-01-14 14:55:42 +08:00
parent 410c920791
commit d504c1ef68
25 changed files with 1204 additions and 2 deletions

6
.gitignore vendored
View File

@ -1,3 +1,9 @@
/.claude
/CLAUDE.md
/AGENTS.md
/logs
/TimiHosts.yaml
# ---> Java # ---> Java
# Compiled class file # Compiled class file
*.class *.class

3
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

14
.idea/compiler.xml generated Normal file
View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile default="true" name="Default" enabled="true" />
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="timi-hosts" />
</profile>
</annotationProcessing>
</component>
</project>

7
.idea/encodings.xml generated Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" defaultCharsetForPropertiesFiles="UTF-8">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

20
.idea/jarRepositories.xml generated Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

12
.idea/misc.xml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_23" default="true" project-jdk-name="temurin-23" project-jdk-type="JavaSDK" />
</project>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -1,3 +1,13 @@
# TimiHost # TimiHosts
host 管理 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`

58
pom.xml Normal file
View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.imyeyu.hosts</groupId>
<artifactId>timi-hosts</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<javafx.version>21.0.2</javafx.version>
</properties>
<dependencies>
<dependency>
<groupId>com.imyeyu.inject</groupId>
<artifactId>timi-inject</artifactId>
<version>0.0.2</version>
</dependency>
<dependency>
<groupId>com.imyeyu.fx.ui</groupId>
<artifactId>timi-fx-ui</artifactId>
<version>0.0.2</version>
</dependency>
<dependency>
<groupId>com.imyeyu.fx.icon</groupId>
<artifactId>timi-fx-icon</artifactId>
<version>0.0.2</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.24</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration>
<mainClass>com.imyeyu.hosts.Launcher</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -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<Config> 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;
}
}

View File

@ -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<Language.Enum> lang;
}

View File

@ -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;
}
}

View File

@ -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<Host> getEntries() {
return hostStore.getEntries();
}
/**
* 刷新数据 - 从系统 hosts 文件中重新加载
*
* @return 加载的 hosts 映射数量
* @throws IOException 读取文件失败时抛出
*/
public int refresh() throws IOException {
List<Host> 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<Host> hosts = new ArrayList<>(hostStore.getEntries());
writer.write(hosts);
log.info("saved, wrote {} hosts mapping", hosts.size());
return hosts.size();
}
}

View File

@ -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<Host> entries;
@PostConstruct
private void postConstruct() {
entries = table.getItems();
}
}

View File

@ -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 文件读取器
*
* <p>负责从系统 hosts 文件中读取并解析 hosts 映射</p>
*
* @author 夜雨
* @since 2026-01-13 17:20
*/
@Slf4j
@Component
public class HostsFileReader {
/**
* 读取并解析系统 hosts 文件
*
* @return Host 对象列表
* @throws IOException 文件读取失败时抛出
*/
public List<Host> read() throws IOException {
Path hostsPath = getHostsFilePath();
log.info("read hosts file: {}", hostsPath);
List<String> lines = Files.readAllLines(hostsPath, StandardCharsets.UTF_8);
List<Host> 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");
}
}
}

View File

@ -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 文件写入器
*
* <p>负责将 hosts 映射写入到系统 hosts 文件</p>
*
* @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<Host> hosts) throws IOException {
Path hostsPath = getHostsFilePath();
// 格式化为文本行
List<String> 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");
}
}
}

View File

@ -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();
}
}

View File

@ -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<Host> implements TimiFXUI {
private final ResourcesMultilingual multilingual;
@PostConstruct
private void postConstruct() {
// 选择
TableColumn<Host, Boolean> 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<Host, String> 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<Host, String> 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<Host, String> 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<Host, String> 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());
}
}

View File

@ -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<Host>) 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();
}
}

View File

@ -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);
}
}

View File

@ -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 文件解析器
*
* <p>提供 hosts 文件内容与 Host 模型的双向转换功能</p>
*
* @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<Host> parse(List<String> lines) {
List<Host> 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<Host> 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<Host> parseLine(String line) {
List<Host> 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<String> format(List<Host> hosts) {
List<String> 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();
}
}

View File

@ -0,0 +1 @@
lang: zh_cn

View File

@ -0,0 +1,6 @@
lang=zh_CN
main.title=Hosts Manager
main.host=地址
main.activated=激活
main.description=说明

View File

@ -0,0 +1,23 @@
<configuration>
<appender name="console" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<charset>UTF-8</charset>
<pattern>[%d{HH:mm:ss.SSS}][%-5level][%-30logger{30}] %msg%n</pattern>
</encoder>
</appender>
<appender name="file" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>./logs/debug.log</file>
<encoder>
<charset>UTF-8</charset>
<pattern>[%d{HH:mm:ss.SSS}][%-5level][%-30logger{30}] %msg%n</pattern>
</encoder>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>./logs/debug/debug.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
</rollingPolicy>
</appender>
<root level="info">
<appender-ref ref="file" />
<appender-ref ref="console" />
</root>
</configuration>

View File

@ -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;
}