系统

系统 API 用于显示主应用 Toast、发送系统通知、打开外部链接,以及局域网自动发现、网段扫描、生成二维码与读取本机网卡地址。

toast

在 ToolZen 当前宿主窗口显示短暂的全局 Toast。主窗口和独立插件窗口都会显示在当前插件所在窗口内,不需要声明权限。

api.toast(message: string, durationMs?: number): void
if (api && api.toast) {
  api.toast('处理完成', 3000)
}

durationMs 为显示时长,单位是毫秒,省略时默认为 3000。Toast 适合反馈一次操作结果,不要用来持续输出日志。

showNotification

发送系统通知,标题缺省使用插件名称。

api.showNotification(body: string, title?: string): Promise<boolean>
const shown = api && api.showNotification
  ? await api.showNotification('处理完成', '文本工作台')
  : false

通知应对应用户能够理解的操作结果,不要用于高频日志输出。

shellOpenExternal

使用系统默认浏览器打开 HTTP(S) 链接。

api.shellOpenExternal(url: string): Promise<boolean>
if (api && api.shellOpenExternal) {
  await api.shellOpenExternal('https://toolzen.top/')
}

宿主只接受 http:// 和 https:// 地址;其他协议会被拒绝。

网络请求请用 api.network.fetch()(需要声明 network:fetch):请求由宿主主进程发出,不受同源策略(CORS)限制,签名与标准 fetch 一致。浏览器自带的 fetch 仍然可用,但它跑在渲染层、受同源策略约束,跨域目标不返回 CORS 头就会失败。详见网络。

showOpenDialog

显示系统文件选择对话框,返回用户选择的路径数组;取消或未声明 file:dialog 时返回空数组。

api.showOpenDialog(options?: {
  title?: string
  defaultPath?: string
  buttonLabel?: string
  filters?: Array<{ name: string; extensions: string[] }>
  properties?: Array<'openFile' | 'openDirectory' | 'multiSelections' | 'showHiddenFiles'>
}): Promise<string[]>

宿主只返回路径,不替插件读取、写入或删除文件。

showSaveDialog

显示系统文件保存对话框,返回用户选择的路径;取消或未声明 file:dialog 时返回空字符串。

api.showSaveDialog(options?: {
  title?: string
  defaultPath?: string
  buttonLabel?: string
  filters?: Array<{ name: string; extensions: string[] }>
}): Promise<string>

shellBeep

播放系统提示音。成功返回 true。

api.shellBeep(): Promise<boolean>

系统外壳操作(system:shell)

这一组会动用户系统里真实的东西,所以单开一项权限 system:shell,不与文件读写混在一起。

api.shellOpenPath(path: string): Promise<boolean>
api.shellTrashItem(path: string): Promise<boolean>
api.getFileIcon(path: string, size?: number): Promise<string>

shellOpenPath

用系统默认程序打开一个文件或文件夹(等同于用户在资源管理器里双击它)。

await api.shellOpenPath('D:\\报告\\季度总结.xlsx')
  • 只受理存在的绝对路径;相对路径或不存在的路径一律回 false,不会走系统调用。
  • 打开失败(没有关联程序、权限不足)也回 false——宿主把系统给的错误描述翻译成了布尔值,插件不必解析错误文本。
  • 注意它会拉起别的应用并把窗口切走,不要在用户没预期的时候调用。

shellTrashItem

把文件或文件夹移进系统回收站。

if (await api.shellTrashItem(target)) {
  api.toast('已移入回收站')
}
  • 这是破坏性操作,虽然进的是回收站(还能还原),但用户会立刻看到文件不见了。只应在用户明确点选「删除」之后调用,并在界面上说清动的是哪个路径。
  • 与 shellOpenPath 同样的路径要求:存在的绝对路径。

getFileIcon

取文件、文件夹或扩展名对应的系统图标,回 Base64 Data URL,可直接塞进 <img src>。

const icon = await api.getFileIcon('D:\\报告\\季度总结.xlsx')
const extIcon = await api.getFileIcon('.pdf')   // 按扩展名问图标,路径不必存在
  • 第二个参数 size 是期望的图标边长(像素);不传用系统默认。
  • 拿不到图标时回空串,不抛错——图标是锦上添花,插件应当能照常显示文件名(比如退回一个默认图标或首字母块)。
  • macOS 上只对 .app 应用包有效(宿主直接读包内的 ICNS),其余路径一律回空串。这是刻意的:macOS 的系统图标接口在部分机型上会让宿主进程崩溃,宿主选择绕开它而不是冒险。

设备与插件目录

getNativeId

读取设备标识:同一台机器上所有插件拿到同一个值。

api.getNativeId(): Promise<string>
  • 它是设备级标识,不是账号标识——没登录也有值,换账号也不变。别拿它当用户 ID 用。
  • 由机器名与平台哈希而来,不含用户名、网卡地址这类能反查到个人的信息,也不落盘、不上报。
  • 机器名变了它就会变,所以不适合做长期唯一标识(比如不能拿它当云端的账号主键)。

getPluginLocalDataPath / getPluginTempPath

取本插件专属的数据目录与临时目录,目录不存在时宿主会自动创建。

api.getPluginLocalDataPath(): Promise<string>
api.getPluginTempPath(): Promise<string>
  • 两个目录都按插件 code 隔离:不同插件拿到的是不同目录,互相读不到对方的数据。
  • 不要自己拼路径。插件跑在渲染层,拿不到 Electron 的 app.getPath,也算不出用户主目录;自己拼出来的路径在别的机器上一定是错的。
  • 返回空串表示宿主创建目录失败(磁盘满、权限不足),插件要有降级方案。
  • 数据目录里的内容不会被自动清理(临时目录会被系统清理)。要长期保存的东西放数据目录,中间产物放临时目录。

应用运行环境

getAppName / getAppVersion

读取宿主应用名称和版本。

api.getAppName(): Promise<string>
api.getAppVersion(): Promise<string>

getAppVersion() 返回的是给人看的版本号(如 0.0.29)。不要拿它判断宿主有没有某个能力——版本号里既有修 bug 也有加能力,比较版本号迟早误判。判断能力请用下面的 getApiLevel() / hasCapability()。

getApiLevel / hasCapability / getCapabilities

探测宿主具备哪些能力。宿主 API 级别(apiLevel)只增不减:新增能力时 +1,级别越高能力越多。

api.getApiLevel(): number
api.hasCapability(name: string): boolean
api.getCapabilities(): string[]
接口 说明
getApiLevel() 宿主 API 级别。当前:1 = 首批能力,2 = 屏幕采集,3 = 读取文件内容 / 局域网发现 / 生成二维码,4 = 网段扫描
hasCapability(name) 宿主是否具备某个能力,名字与 permissions 一致(如 screen:capture)
getCapabilities() 宿主全部能力名,按首次出现的级别排序

这三个接口不需要任何权限:它们只回答「宿主会不会」,不读也不改用户数据。

注意区分两件事:hasCapability() 回答的是「这台客户端会不会」,不代表「你的插件有没有被授权」。后者看 manifest.permissions,由权限门管。两者都要满足才能真正调用——能力缺失是版本问题(要升级客户端),权限缺失是插件自身声明问题(改 manifest 重发即可)。

老客户端上这三个方法不存在,直接调用会抛 TypeError。必须用兜底写法:

/** 探测宿主能力;老客户端没有 hasCapability,缺方法即视为不支持。 */
function supports(api, name) {
  try {
    return typeof api.hasCapability === 'function' && api.hasCapability(name) === true
  } catch {
    return false
  }
}

if (supports(api, 'screen:capture')) {
  const stream = await api.capture.getStream({ sourceId })
} else {
  api.toast('屏幕录制需要升级 ToolZen 客户端')
}

更省事的做法是在 manifest.json 里声明 requires / optional,让宿主在加载前替你判定,并把结果直接告诉用户。详见版本与兼容性。

getPlatform

返回 Electron 的平台标识,例如 win32、darwin、linux。

api.getPlatform(): string

isDev

判断当前是否为本地调试插件。通过开发者专区登记的插件返回 true,商店或远程插件返回 false。

api.isDev(): boolean

isMacOS / isWindows / isLinux

按当前平台返回布尔值。

api.isMacOS(): boolean
api.isWindows(): boolean
api.isLinux(): boolean

局域网自动发现(api.lan)

让同一局域网内两台装了 ToolZen 的电脑互相看见,不需要任何服务器,数据不出本地网络。需要声明 lan:discover。

插件跑在渲染进程里,不能监听端口,所以组播套接字由宿主主进程代管。宿主在管理范围组播地址(239.255.77.90:47823)上收发一个小 JSON 报文,只在本网段传播(TTL = 1)。

api.lan.advertise(request: {
  service: string    // 服务标识,最长 40 字符;同一项服务的对端才会互相发现
  name?: string      // 展示名,通常是本机名
  payload?: string   // 交给对端的载荷,最长 2048 字符
}): Promise<{ ok: boolean; id?: string; name?: string; code?: string; message?: string }>

api.lan.discover(request: {
  service: string
  timeoutMs?: number  // 等待时长,默认 3000,上限 15000
}): Promise<{
  ok: boolean
  peers: Array<{ id: string; service: string; name: string; address: string; payload: string }>
  code?: string
  message?: string
}>

api.lan.stop(id?: string): Promise<boolean>

最小用法(两端都调同一段代码即可):

// 一侧:把自己的连接码挂上去播报。
const advertised = await api.lan.advertise({
  service: 'lan-file-transfer',
  name: '我的电脑',
  payload: connectionCode
})

if (!advertised.ok) {
  // 网络屏蔽了组播:退回「手工复制粘贴连接码」那条路,别卡在「正在搜索」。
  api.toast('当前网络不支持自动发现,请手工交换连接码')
}

// 另一侧:搜一轮。
const found = await api.lan.discover({ service: 'lan-file-transfer', timeoutMs: 3000 })
for (const peer of found.peers) {
  console.log(peer.name, peer.address, peer.payload)
}

// 收工时停掉(插件卸载时宿主也会自动收摊)。
await api.lan.stop(advertised.id)

语义要点:

  • discover 是「探一次、等一会儿」,不是持续订阅。 它只返回这一轮听到的对端(每次调用会清掉上一轮的记录)。要长期盯着就自己隔几秒调一次——一次调用只占一个短窗口,不会在插件被卸载后还留一条监听。
  • 对端超过 10 秒没有心跳就算下线(宿主每 3 秒播报一次)。所以别把 discover 的结果长期缓存。
  • payload 最长 2048 字符,正好够放一条 SDP 连接码。更大的内容请走已经建立好的数据通道。
  • payload 是同网段可见的:不要把密码、令牌这类敏感内容放进去。
  • 窗口销毁时广告自动撤下,插件不必在 onPluginOut 里额外收尾;stop(id) 只肯停自己登记的广告。

组播被网络屏蔽时

企业 WiFi、访客网络、部分路由器会屏蔽组播。此时 advertise / discover 会如实回 ok: false(code: 'unavailable',message 里是原因),不是回一个空的对端列表——两者含义不同,前者是「这条路走不通」,后者是「这一轮没人应答」。

插件应当据此退回手工交换连接码,而不是让用户一直看「正在搜索」:

const found = await api.lan.discover({ service: 'lan-file-transfer' })

if (!found.ok) {
  // 组播不可用:显示「手工输入连接码」的入口。
  mode.value = 'manual'
} else if (!found.peers.length) {
  // 组播可用但没人应答:提示对方也打开插件。
  api.toast('没有搜到设备,请确认对方也打开了本插件')
}

网段扫描(api.lan.scan)

列出本机所在网段上此刻在线的设备。需要声明 lan:scan(与上面的 lan:discover 是不同的权限,见权限与能力矩阵)。

这和上面那组「局域网自动发现」回答的是两个不同的问题:

lan.discover(lan:discover) lan.scan(lan:scan)
问的是 「这个网段上有哪些装了 ToolZen 的设备」 「这个网段上有哪些设备」
手段 组播小报文(只在自己人之间往来) 朝每台设备发 TCP 连接
对端要求 对方也装了 ToolZen 并开着插件 无,路由器、打印机、手机都算
结果 对端的 name 与 payload 地址、MAC、开放端口

先选网段:getInterfaces

api.lan.getInterfaces(): Promise<Array<{
  id: string            // 网卡标识
  name: string          // 网卡名,如 'WLAN' / '以太网' / 'en0'
  address: string       // 本机在这块网卡上的地址
  prefixLength: number  // 子网前缀,如 24
  netmask: string
  kind: 'wifi' | 'ethernet' | 'virtual' | 'loopback' | 'unknown'   // 启发式判定,只用于显示
  isDefault: boolean    // 是否承担默认路由(路由器所在的那块网卡)
  networkId: string     // 网段标识,如 '192.168.1.0/24' —— 扫描时用它
  mac: string
}>>

只包含 IPv4、非回环的网卡。IPv6 不列:一个 /64 前缀下有 2^64 个地址,逐台探测不是这套接口能做的事,列出来只会诱使插件去扫一个永远扫不完的网段。

kind 是启发式判定的,虚拟机网卡优先于有线网卡(因为 vEthernet 里也含 ethernet,顺序反了就会把虚拟网卡认成有线网卡)。它只适合拿来做图标,别拿它做逻辑判断。

isDefault 是全表最有用的一个字段:多网卡机器上(VPN、WSL、Hyper-V、Docker 都会加网卡)应当优先展示它,而不是数组第一条——networkInterfaces 的顺序既不是路由优先级,也不是接口优先级。

扫描

api.lan.scan(request: {
  requestId: string     // 本轮扫描的标识,自己生成;取消时用它
  interfaceId: string   // 取自 getInterfaces()
  networkId: string     // 取自 getInterfaces();必须逐字命中本机某块网卡当前的网段
  timeoutMs?: number    // 时长上限(毫秒),默认 20000,范围 1000–120000
}): Promise<{
  ok: boolean
  requestId: string
  devices: Array<{
    address: string
    mac: string         // 扫描后从系统邻居表补的;读不到是空串
    hostname: string    // 确认在线后尽力解析的本地名称;解析不到是空串(不是 undefined)
    hostnameSource: 'mdns' | 'netbios' | 'llmnr' | 'ssdp' | 'dns' | ''  // 名字来源,与 hostname 同时给出
    // mac:ARP 探测到的必然有值;其余来源是扫完后从系统邻居表补的,读不到是空串
    evidence: 'port-open' | 'host-rejected' | 'arp-reply'
    openPorts: number[] // evidence 不是 'port-open' 时是空数组
    self: boolean
  }>
  scannedAt: number     // 本轮结束时刻(epoch 毫秒)
  coverage: {
    interfaceId: string
    networkId: string
    total: number       // 网段内的主机总数(截断前)
    probed: number      // 本轮真正探测过的地址数
    truncated: boolean  // 是否因为网段太大只扫了其中一段
    elapsedMs: number
  }
  code?: string         // 'ok' | 'timeout' | 'cancelled' | 'invalid' | 'too-many'
  message?: string
}>

api.lan.cancelScan(requestId: string): Promise<boolean>

最小用法:

const interfaces = await api.lan.getInterfaces()
// 优先用承担默认路由的那块网卡。
const target = interfaces.find((item) => item.isDefault) || interfaces[0]
if (!target) {
  api.toast('这台机器没有可扫描的网段')
  return
}

const requestId = 'scan-' + Date.now()
const result = await api.lan.scan({
  requestId,
  interfaceId: target.id,
  networkId: target.networkId,
  timeoutMs: 20000
})

if (!result.ok) {
  // code: 'cancelled' / 'invalid' / 'too-many'——message 里有具体原因。
  console.warn(result.message)
}

for (const device of result.devices) {
  // 有名字就显示名字,没有就退回地址——hostname 永远是字符串,不必判 undefined。
  console.log(device.hostname || device.address, device.mac, device.openPorts.join('/'))
}

// 结果不全时如实告诉用户,而不是假装扫完了。
if (result.code === 'timeout' || result.coverage.truncated) {
  api.toast('结果可能不完整,可再扫一次或缩短范围')
}

「在线」是怎么判定的

devices 里的每一台都带本轮真的收到了对方的报文这一个依据,evidence 有三种取值:

  • port-open:某个端口完成了 TCP 三次握手,对端主动应答了;
  • host-rejected:某个端口回了 RST(端口没开),但这个 RST 是对方亲自回的,同样证明它此刻在网段上;
  • arp-reply:宿主对这台主机发了 ARP 请求,收到了它的 ARP 应答。

宿主同时跑 TCP 探测与 ARP 探测两条路,结果合并成一份清单:

  • ARP 补上了 TCP 探不到的那一类——端口全关但在线的设备(嵌入式、自研服务、关了文件共享的机器)。实测同一个 /24 网段上,只跑 TCP 认出 22 台,加上 ARP 是 42 台。
  • 两条路的证据都算数,但同一条设备只报一个。 合并时:如果这台设备有端口开着,报 port-open 并保留端口列表(端口是更有用的信息,不会为了统一标签把它抹掉);否则报 arp-reply。所以看到 arp-reply 表示「它在线,但没有探测到开放端口」,而不是「我们只发了 ARP」。
  • arp-reply 的设备 mac 必然可用——ARP 应答本身就带 MAC,这是这条路径相对 TCP 的核心优势。TCP 那条要到扫描结束后才去查系统邻居表,查不到就是空串。

系统 ARP 缓存不参与在线判定。 那张表里的条目能存活很久,拔了网线、关了机的机器照样躺在里面;拿它当依据,界面上就会一直挂着一台早就不在的设备。ARP 探测走的是当场发请求、等应答,不是读表;邻居表只在最后用来给还没有 MAC 的设备补一个值,且不覆盖 ARP 已经拿到的。

反过来说:没响应就是没进清单。防火墙静默丢弃(DROP)的设备是扫不出来的,这与「不在线」在结果上无法区分——插件不要把它解释成「这台机器肯定不在」。

平台差异

ARP 探测在三个平台上的实现不同,强度也略有差别:Windows 上直接调系统接口(SendARP),应答来自这次请求本身;macOS 与 Linux 上没有等价调用,宿主靠并发 ping 触发系统的 ARP 解析、再读邻居表——在这两个平台上,「本轮」的强度弱于 Windows,但网段过滤与 mac 可用性是一致的。

设备名(hostname)

确认一台设备在线之后,宿主会尽力解析一次它的本地名称,填进 hostname,并用 hostnameSource 说明这个名字是从哪儿来的:

hostnameSource 来源 典型设备
'mdns' mDNS(224.0.0.251:5353) Apple 设备、打印机、Linux 主机
'llmnr' LLMNR(224.0.0.252:5355) Windows 机器
'netbios' NetBIOS 名字服务(UDP 137) Windows 机器、多数 NAS
'ssdp' SSDP/UPnP(239.255.255.250:1900) 路由器、智能家电、媒体服务器
'dns' 系统解析器(反向查询) 走本地 DNS 的网络

几条要记住的:

  • 解析不到就是空串,与 mac 同一约定。hostname 与 hostnameSource 同进同退:有名字就一定有来源,没名字时两个都是空串。字段永远在,插件可以直接 device.hostname || device.address,不必判 undefined。
  • 名字不影响设备是否出现。 名字是在设备已经确认在线之后才去问的,问不到只是没名字,设备照样在 devices 里。反过来说,别拿名字当在线依据——名字解析不到的原因很多(对方禁了这些协议、防火墙挡了 UDP、跨了 VLAN),跟它在不在线无关。
  • 解析不保证成功,也不保证准确。 很多网络(尤其是企业网与访客网)会把这些协议全挡掉,此时全部设备都是空串——这是正常结果,插件界面上应当退回显示 IP。ssdp 给的往往是型号或服务端标识(如 Linux/3.14 UPnP/1.0),不是机器名,别当成设备名去重。
  • ssdp 的优先级最低(mdns → llmnr → netbios → ssdp → dns),同一个地址被多个协议认出来时,取更靠前那个给的名字。
  • 解析有总预算(约 2.5 秒,且最多问 64 台),所以它不会明显拖长扫描;但设备很多、网络很慢时,排在后面的设备可能来不及问,同样是空串。

可扫范围

networkId 必须逐字命中本机某块网卡当前的网段(地址与掩码都要对上)。不匹配时 scan 回 { ok: false, code: 'invalid' },一发包都不发。

这不是防谁,而是不让这套接口变成一台任意网段的扫描器:插件能扫的范围,与用户这台电脑此刻所在的网络完全重合。用户换了网络,可扫的网段就跟着变;用户没连的网络,插件一个包也发不出去。

几个边界:

  • 网段超过 1024 台主机时只扫前面一段,coverage.truncated 为 true、coverage.total 报的是网段实际大小。/24(254 台)是最常见的情况,一次能扫完;/16 那种会截断。TCP 与 ARP 两条路共用这个上限,不会因为多了一条路就发得更远。
  • 两条路都受 timeoutMs 约束:撞上上限会提前收工,已经扫到的那部分照常交回;取消也会同时掐断两条路(包括 ARP 那批子进程),不会出现「点了停止还要等十几秒」。
  • 单次扫描最多 120000 毫秒;撞上上限会提前收工,code 为 'timeout',devices 里是已经扫到的那部分,不是空的——大网段上时间不够是常态,把扫到的一半丢掉才是真的浪费。插件应当照常展示,并提示结果可能不全。
  • 同一个插件实例同时最多跑 2 轮扫描;超了回 { ok: false, code: 'too-many' }。
  • 同一个 requestId 不许重入。

取消

const pending = api.lan.scan({ requestId, interfaceId, networkId })
// 用户按了「停止」,或者插件要退出:
const cancelled = await api.lan.cancelScan(requestId)
const result = await pending
// result.ok === false,result.code === 'cancelled'
  • 只取消得动本插件实例发起的那些。 同一个窗口里另一个插件、或者别的窗口,拿了 requestId 也停不了(回 false)。
  • 找不到就回 false:已经跑完、已经被取消过、或者压根不是自己发起的,都算找不到。
  • 插件退出时宿主自动收掉它名下所有还在跑的扫描(组件卸载、窗口关闭都会触发),插件不必在 onPluginOut 里额外收尾。
  • 被取消时 devices 里是已经扫到的那部分,同样每台都带证据——可以显示「扫到一半被停了」,而不是清空。

读取本机网卡地址(getLocalAddresses)

列出本机的网卡地址,用于排查「两台电脑不在同一网段」这类连接失败。

api.getLocalAddresses(): Promise<Array<{
  interface: string          // 网卡名,如 'WLAN' / '以太网'
  address: string
  family: 'IPv4' | 'IPv6'
  internal: boolean          // 回环地址(127.0.0.1 / ::1)
}>>

不需要任何权限:只读、不含用户数据,而且这些地址在同网段本来就是公开的。仍登记为 lan:discover 能力,让老客户端能被 requires 拦下。

它和 api.lan.getInterfaces() 的区别:这个是「这台机器有哪些地址」的老接口,含回环、含 IPv6,不给你网段;要扫网段请用 api.lan.getInterfaces()(它只列可扫的 IPv4 网卡,并带 networkId 与 isDefault),那个登记在 lan:scan 能力下、4 级起可用。

const addresses = await api.getLocalAddresses()
const lan = addresses.filter((item) => !item.internal && item.family === 'IPv4')

if (lan.length > 1) {
  // 多张网卡时提示用户确认对方在同一网段。
  api.toast(`本机有 ${lan.length} 个局域网地址:${lan.map((item) => item.address).join('、')}`)
}

拿不到网卡信息时返回空数组(老宿主上这个接口不存在,也回空数组),不会抛错。

生成二维码(renderQrCode)

把一段文本渲染成二维码 PNG Data URL。

api.renderQrCode(request: {
  text: string
  size?: number    // 图片边长(像素),默认 320,范围 64–1024
  margin?: number  // 静默区宽度(模块数),默认 2,范围 0–8
  level?: 'L' | 'M' | 'Q' | 'H'   // 纠错级别,默认 'M'
}): Promise<{ ok: boolean; dataUrl?: string; size?: number; code?: string; message?: string }>

不需要任何权限:纯计算,不读也不改用户数据。仍登记为 qrcode:render 能力,让老客户端能被 requires 拦下。

典型用途是把长连接码变成二维码,对端扫码即可,省掉「复制长文本漏字符」这一类问题:

const qr = await api.renderQrCode({ text: connectionCode, size: 320 })

if (qr.ok) {
  // 直接塞进 <img src>。
  image.value = qr.dataUrl
  // 也可以复制到剪贴板(需要 clipboard:write)。
  await api.copyClipboardImage(qr.dataUrl)
} else {
  api.toast(`生成二维码失败:${qr.message || qr.code}`)
}

文本超出二维码容量时会回 { ok: false, code: 'failed' },不会给一张扫不出来的图。

端口与进程(ports:read / ports:kill)

排查「谁占了我的 3000 端口」、结束一个把端口占死的残留进程。两项能力都是 apiLevel 9 引入的,分成两项权限:ports:read 只读,ports:kill 会动用户机器上正在跑的进程。

type PluginPortEntry = {
  address: string        // 监听地址,如 '0.0.0.0' / '::' / '127.0.0.1'
  port: number
  protocol: 'tcp' | 'udp'
  pid: number            // 占用该端口的进程;取不到时为 0
  processName: string    // 进程名;取不到时为空串
  processPath: string    // 进程可执行文件路径;取不到时为空串
}

api.ports.list(): Promise<{
  ok: boolean
  ports: PluginPortEntry[]
  message?: string       // ok 为 false 时的原因
}>

api.ports.kill(request: {
  pid: number                        // 必填:要结束的进程
  port?: number                      // 只用于结果可读性,不参与定位
  protocol?: 'tcp' | 'udp'
  force?: boolean                    // 强杀:Windows 走 /F,POSIX 走 SIGKILL
  confirmed: boolean                 // 必须为 true,否则不执行
}): Promise<{
  ok: boolean
  code?: 'not-confirmed' | 'invalid' | 'not-found' | 'denied' | 'failed'
  message?: string
  pid?: number
}>

list:枚举监听端口

const listed = await api.ports.list()
if (!listed.ok) {
  // 平台不支持、系统命令不可用(比如没装 lsof)等,message 里是原因。
  api.toast(`读不到端口列表:${listed.message || '未知原因'}`)
  return
}

// 「谁占了我的 3000」
const hit = listed.ports.find((p) => p.port === 3000)
if (hit) {
  console.log(`${hit.processName} (PID ${hit.pid}) 占用了 ${hit.address}:${hit.port}/${hit.protocol}`)
}
  • 返回所有监听中的端口,IPv4 与 IPv6、tcp 与 udp 都在内。同一个端口可能出现在多条记录里(不同地址、不同协议,或同端口被多个进程共享)。
  • ok: false 时 ports 是空数组,message 说明原因。空数组也可能只是「真没有监听端口」,两者要用 ok 区分。
  • pid / processName / processPath 是尽力而为的:系统不给权限或进程已退出时回 0 / 空串,不要把空进程名当成「无主端口」。

kill:结束进程

kill 结束的是整个进程,不是「关掉某个端口」。一个进程往往同时占着好几个端口,把它们一起断开;进程里正在跑的任务也会一并终止。这件事无法撤销,调用前必须让用户确认。

宿主不会自己弹确认框。confirmed 不为 true 时直接回 { ok: false, code: 'not-confirmed' },不会做任何事——这是硬门槛,不存在「默认放行」。

const listed = await api.ports.list()
if (!listed.ok) return
const ports = listed.ports

const hit = ports.find((p) => p.port === 3000)
if (!hit) return
// 必须让用户看清楚要结束谁:进程名 + PID + 端口
const ok = await confirmDialog(`结束 ${hit.processName} (PID ${hit.pid})?它会占用的所有端口都一起断开。`)
if (!ok) return
const result = await api.ports.kill({ pid: hit.pid, port: 3000, protocol: hit.protocol, confirmed: true })

if (!result.ok) {
  // code: 'invalid' / 'not-found' / 'denied' / 'failed',message 里是具体原因。
  api.toast(`结束进程失败:${result.message || result.code}`)
}

只按 PID 定位,不支持按端口批量杀。 传 port 与 protocol 只影响返回结果的可读性,宿主不会拿它们去找进程:一个端口可能同时被多个进程共享(Windows 的 SO_REUSEADDR、多个地址上的监听),端口号也会被系统快速复用给新进程。按端口杀很容易杀掉用户没预期的那一个。要杀谁,先用 list() 拿到它的 PID,再把 PID 交给 kill()。

返回的 code:

code 含义
not-confirmed confirmed 不是 true,未执行
invalid 请求不合法,如 pid 不是正整数
not-found 该 PID 不存在,或进程已经退出
denied 系统拒绝(权限不足、进程受保护)
failed 其它失败,message 里是系统给出的原因

平台差异

平台 list() kill()
Windows netstat -ano 取端口与 PID,再补进程名与路径 taskkill;force: true 时带 /F
macOS / Linux lsof 取监听端口与进程 向 PID 发信号;force: true 时用 SIGKILL,否则 SIGTERM
  • lsof 不存在时 list() 回 ok: false(ports 为空数组,message 说明原因)。精简过的 Linux 容器、部分定制系统上没有 lsof,插件要把这条路径当成正常结果处理,而不是让界面停在「加载中」。
  • macOS 上 lsof 也可能因为系统权限(TCC)看不到别的用户的进程,此时那些端口的进程字段会退化为 0 / 空串。

老客户端上

这两项能力在 apiLevel 9 引入。老宿主的 api.ports 整个不存在,直接调用会抛 TypeError:

if (typeof api?.ports?.list !== 'function') {
  api.toast('端口查看需要升级 ToolZen 客户端')
  return
}

用 api.ports.list 的插件应在 manifest 里声明 optional: ["ports:read"](杀进程再加 ports:kill),让老客户端在加载前就把缺失项告诉用户;只有整个插件都依赖端口能力时才写 requires。详见版本与兼容性。