Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Get `LeftOpen-release.zip` from [GitHub Releases](https://github.com/SonghaiFan/

- **Knows whose port it is.** Walks up from the process's working directory to `.git`, `package.json`, `pyproject.toml`, `Cargo.toml` or `go.mod`, or resolves the owning `.app`. Global npm packages, `python -m` modules and standalone services (Redis, Postgres, Ollama…) are named too, so you rarely see a bare `node` or `python`.
- **Real icons, nothing bundled.** Uses the `.app` icon when there is one; otherwise the icon the project or package ships itself (Tauri/Electron app icon, the favicon declared in `index.html`, `public/` conventions); otherwise the favicon served by the local server; otherwise a symbol.
- **Grouped by who started them.** Dev Servers (from a project, terminal, editor or agent), Background Services (launchd: brew services, login items), Apps and System. The group is also how a port is closed for good, and each one says so: SIGTERM for dev servers, `brew services stop …` for services launchd would restart, quitting the app for apps.
- **Grouped by who started them.** Dev Servers (from a project, terminal, editor or agent), Background Services (launchd: brew services, login items), Containers (forwarded by Docker Desktop, OrbStack or colima; the Engine API resolves the container's name, compose project and mounted directories), Apps and System. The group is also how a port is closed for good, and each one says so: SIGTERM for dev servers, `brew services stop …` for services launchd would restart, one click `docker stop` for containers on their port page, quitting the app for apps.
- **Closes gently first.** Sends `SIGTERM` and re-checks process identity. If a single-process close still leaves it listening after five seconds, that process is marked and the warning explains that closing it again within two minutes sends `SIGKILL`. The second click or swipe is the deliberate force-close action; existing safety protections and identity checks still apply.
- **Local vs LAN.** Tells apart ports bound to `127.0.0.1` from ones on `0.0.0.0` or a LAN address.
- **Hidden ports.** Hide selected ports from the list, search, menu bar count, and change sounds in **Settings → Ports → Hidden Ports**, or choose **Hide Port** from its context menu. Enter ports with Return or commas, or paste several at once. Each saved port becomes a tag; click its × to show that port again. Services keep running.
Expand All @@ -64,6 +64,8 @@ Get `LeftOpen-release.zip` from [GitHub Releases](https://github.com/SonghaiFan/
- **Fixed project addresses.** Give a dev server a stable `https://myapp.localhost` URL that follows it across port changes, powered by bundled [Portless](https://github.com/vercel-labs/portless). See [below](#fixed-project-addresses).
- **Same engine in the terminal.** The app ships a `leftopen` CLI with identical inference and safety rules.

Container stop actions are limited to verified local runtime endpoints. Ambiguous ownership stays read-only; see [container safety](docs/container-stop-safety.md).

---

## Fixed project addresses
Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ brew install --cask songhaifan/tap/leftopen

- **知道端口是谁的。** 从进程的工作目录向上找 `.git`、`package.json`、`pyproject.toml`、`Cargo.toml`、`go.mod`,或者解析它所属的 `.app`。全局 npm 包、`python -m` 模块和 Redis / Postgres / Ollama 这类独立服务也按名字识别,很少再看到一个光秃秃的 `node` 或 `python`。
- **真实图标,不预存。** 有 `.app` 就用它的图标;否则用项目或包自己带的(Tauri / Electron app icon、`index.html` 声明的 favicon、`public/` 约定);否则向本机服务器抓 favicon;再没有才用符号。
- **按来历分组。** 开发服务器(从项目、终端、编辑器或 agent 启动)、后台服务(由 launchd 管理:brew services、登录项)、App 和系统。分组即关闭方式,每组都写明:开发服务器发 SIGTERM,launchd 会重启的服务提示 `brew services stop …`,App 的端口需要退出 App。
- **按来历分组。** 开发服务器(从项目、终端、编辑器或 agent 启动)、后台服务(由 launchd 管理:brew services、登录项)、容器(由 Docker Desktop、OrbStack、colima 转发,通过 Engine API 解析出容器名、compose 项目和挂载目录)、App 和系统。分组即关闭方式,每组都写明:开发服务器发 SIGTERM,launchd 会重启的服务提示 `brew services stop …`,容器可在详情页一键 `docker stop`,App 的端口需要退出 App。
- **先轻轻关闭。** 发送 `SIGTERM`,关闭前重新核对进程身份。单独关闭一个进程后等待五秒,如果仍在监听,该进程会显示失败提示;警告会说明两分钟内再次点击或右滑关闭将发送 `SIGKILL`。第二次关闭操作就是明确的强制关闭,原有安全保护和身份核验仍然生效。
- **本机还是局域网。** 区分绑在 `127.0.0.1` 的端口和绑在 `0.0.0.0` / 局域网地址上的端口。
- **隐藏端口。** 在 **设置 → 端口 → 隐藏端口** 中添加端口,或右键选择 **隐藏端口**,即可从列表、搜索、菜单栏计数和变化提示音中排除。输入端口后按回车或逗号添加,也可一次粘贴多个端口。已保存的端口显示为标签,点击 × 后恢复显示。服务仍继续运行。
Expand All @@ -65,6 +65,8 @@ brew install --cask songhaifan/tap/leftopen
- **固定项目地址。** 为开发服务器分配稳定的 `https://myapp.localhost`,端口变化后自动跟随,由内置的 [Portless](https://github.com/vercel-labs/portless) 提供。详见[下文](#固定项目地址)。
- **终端里是同一个引擎。** App 附带 `leftopen` 命令行工具,推断和安全规则完全一致。

停止容器仅适用于已验证的本机运行时端点;归属不明确时只展示,不提供停止操作。详见 [容器安全机制](docs/container-stop-safety.md)。

---

## 固定项目地址
Expand Down
73 changes: 68 additions & 5 deletions Sources/LeftOpenApp/AppMain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ final class MenuModel: ObservableObject {
@Published var isClosing = false
@Published var pendingPlan: ClosePlan?
@Published var pendingBatchPlans: [ClosePlan]?
@Published var pendingContainerStop: ContainerStopPlan?
@Published private(set) var notice: Notice?
@Published private(set) var forceCloseOffer: ForceCloseOffer?
@Published var selectedActivityID: String?
Expand All @@ -81,7 +82,7 @@ final class MenuModel: ObservableObject {
@Published private(set) var closingActivityIDs: Set<String> = []

private var refreshLoop: Task<Void, Never>?
private var scanLoop: Task<Void, Never>?
private var scanLoop: Task<ScanSnapshot?, Never>?
private var rescanRequested = false
private var settingsObserver: AnyCancellable?
private var languageObserver: AnyCancellable?
Expand Down Expand Up @@ -132,6 +133,7 @@ final class MenuModel: ObservableObject {
MainActor.assumeIsolated {
self?.notice = nil
self?.forceCloseOffer = nil
self?.pendingContainerStop = nil
Task { await self?.refresh() }
}
}
Expand All @@ -142,6 +144,7 @@ final class MenuModel: ObservableObject {
MainActor.assumeIsolated {
self?.pendingPlan = nil
self?.pendingBatchPlans = nil
self?.pendingContainerStop = nil
self?.forceCloseOffer = nil
}
}
Expand Down Expand Up @@ -174,18 +177,23 @@ final class MenuModel: ObservableObject {
/// scan loop: a request that arrives mid-scan queues a single rescan instead of a parallel one,
/// so a refresh after a close never settles for a snapshot taken before the signal.
func refresh() async {
_ = await refreshWithResult()
}

/// Nil means the requested fresh scan failed; the last displayed snapshot is not evidence.
private func refreshWithResult() async -> ScanSnapshot? {
if let scanLoop {
rescanRequested = true
await scanLoop.value
return
return await scanLoop.value
}
let loop = Task { await runScans() }
scanLoop = loop
await loop.value
return await loop.value
}

private func runScans() async {
private func runScans() async -> ScanSnapshot? {
isRefreshing = true
var freshSnapshot: ScanSnapshot?
defer {
isRefreshing = false
scanLoop = nil
Expand All @@ -194,6 +202,7 @@ final class MenuModel: ObservableObject {
rescanRequested = false
do {
snapshot = try await Task.detached(priority: .utility) { try Scanner.scan() }.value
freshSnapshot = snapshot
lastRefresh = Date()
if let offer = forceCloseOffer {
if !snapshot.activities.contains(where: {
Expand All @@ -214,10 +223,12 @@ final class MenuModel: ObservableObject {
}
previousListeningPorts = listeningPorts
} catch {
freshSnapshot = nil
forceCloseOffer = nil
post(Notice(kind: .error, text: L("Scan failed: \(error.localizedDescription)", "扫描失败:\(error.localizedDescription)")))
}
} while rescanRequested
return freshSnapshot
}

func previewClose(_ activity: Activity) async {
Expand Down Expand Up @@ -246,6 +257,58 @@ final class MenuModel: ObservableObject {
await execute(plan)
}

/// Freeze the daemon, immutable ID and all published bindings in a fresh, reviewed plan.
func previewContainerStop(_ activity: Activity) async {
guard !isPreparingClose && !isClosing, activity.container != nil else { return }
isPreparingClose = true
notice = nil
defer { isPreparingClose = false }
do {
pendingContainerStop = try await Task.detached(priority: .utility) {
try ContainerResolver.prepareStop(activity)
}.value
} catch {
post(Notice(kind: .warning, text: L("Stop unavailable: \(error.localizedDescription)",
"无法停止:\(error.localizedDescription)")))
}
}

func confirmContainerStop() async {
guard let plan = pendingContainerStop, !isClosing else { return }
isClosing = true
defer { isClosing = false }
// Consume the confirmation once. The immutable plan, never the polling snapshot, supplies impact.
pendingContainerStop = nil
do {
_ = try await Task.detached(priority: .utility) {
try ContainerResolver.stop(plan)
}.value
selectedActivityID = nil
let fresh = await refreshWithResult()
let outcome: String
let kind: NoticeKind
switch ContainerStopVerification.evaluate(plan, freshSnapshot: fresh) {
case .free:
outcome = L("Container \(plan.container.name) stopped; TCP port \(plan.activity.listener.port) is free.",
"容器 \(plan.container.name) 已停止,TCP 端口 \(plan.activity.listener.port) 已释放。")
kind = .success
case let .stillListening(ports):
let held = ports.map(String.init).joined(separator: ", ")
outcome = L("Container \(plan.container.name) stopped, but TCP ports \(held) still listen. Another owner may hold them.",
"容器 \(plan.container.name) 已停止,但 TCP 端口 \(held) 仍在监听,可能已由其他进程占用。")
kind = .warning
case .unavailable:
outcome = L("Container \(plan.container.name) stopped; verification unavailable because the fresh scan failed. Port release is not confirmed.",
"容器 \(plan.container.name) 已停止,但最新扫描失败,无法验证端口是否已释放。")
kind = .warning
}
post(Notice(kind: kind, text: outcome))
} catch {
post(Notice(kind: .error, text: L("Container stop failed or was refused: \(error.localizedDescription)",
"容器停止失败或已拒绝:\(error.localizedDescription)")))
}
}

/// Swipe-to-close: the swipe past the threshold is the confirmation, so the plan is prepared
/// and executed without the review page. Identity checks in CloseService still apply.
func closeNow(_ activity: Activity) async {
Expand Down
Loading