用 NW.js 开发托盘应用
这个示例构建一个常驻系统托盘的剪贴板历史:它记下在任何程序里复制的文本,应用窗口可以列出和搜索这些文本、把其中一条再次复制到剪贴板,还可以置顶要保留的条目。它从 nwjs-react 模板开始,展示用 lakutata 开发的 NW.js 应用由哪几部分组成:一是后台脚本,它是一个 lakutata 应用,只要应用在运行它就在运行,不管有没有窗口(这里它负责读取剪贴板,并维护托盘图标和托盘菜单);二是窗口,窗口中的 React 用户界面调用应用的动作,并接收应用推送的事件;三是应用的测试,测试不需要 NW.js 就能运行。
| 消息 | 作用 |
|---|---|
{cmd: 'clips.list'} | 列出剪贴项,置顶的在前,然后是最新的 |
{cmd: 'clips.list', query: 'git'} | 只列出文本包含 git 的剪贴项,不区分大小写 |
{cmd: 'clips.copy', id: '…'} | 把一条剪贴项再次复制到剪贴板,它会移到最前 |
{cmd: 'clips.pin', id: '…', pinned: true} | 置顶一条剪贴项:之后不管复制多少文本,它都会保留 |
{cmd: 'clips.delete', id: '…'} | 删除一条剪贴项 |
{cmd: 'clips.clear'} | 删除置顶以外的所有剪贴项 |
历史有变化时,应用会向窗口推送 clips.changed 事件。
1. 创建项目
npx @lakutata/cli create --name clips --template nwjs-react --yes
cd clips
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts项目是一个工作区(见 ProjectStructure):packages/app 是 NW.js 的后台脚本,也是一个 lakutata 应用;packages/ui 是用户界面,即由 Vite 构建的 React 页面;packages/resources 存放图标。应用不需要安装其他包:剪贴板和托盘由 NW.js 提供,历史是用 node:fs 写入的 JSON 文件。这里删掉了模板中的示例控制器。
2. 输入
packages/app/src/dtos/ClipDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* 文本包含查询词的剪贴项,没有查询词时为全部
*/
export class SearchClipsDTO extends DTO {
@Expect(DTO.String().trim().max(200).optional())
public query?: string
}
/**
* 按 id 指定的一条历史剪贴项
*/
export class ClipIdDTO extends DTO {
@Expect(DTO.String().uuid().required())
public id: string
}
/**
* 要置顶或取消置顶的剪贴项
*/
export class PinClipDTO extends ClipIdDTO {
@Expect(DTO.Boolean().required())
public pinned: boolean
}每个动作运行前都先用 DTO 校验输入(见 DTOValidation)。剪贴项用 id 指定,id 是历史生成的 UUID:.uuid() 拒绝其他字符串,报错 "id" must be a valid GUID。消息是 JSON,值会保留原来的类型:pinned 必须是布尔值。
3. 异常
packages/app/src/exceptions/ClipExceptions.ts
import {Exception} from 'lakutata'
/**
* 剪贴项不在历史中(或已不在)
*/
export class ClipNotFoundException extends Exception {
public errno: string = 'E_CLIP_NOT_FOUND'
}异常带有 errno,用户界面根据这个代码判断错误(见 Exceptions)。窗口收到的错误和抛出时一样,带有全部属性(见 ServiceCalls 第 3 节):error.errno 是 'E_CLIP_NOT_FOUND'。服务传输方式无论错误的状态码是什么,都用统一的响应结构返回,所以这个异常不需要 statusCode。
4. 历史
packages/app/src/components/History.ts
import {Component, DTO} from 'lakutata'
import {type Entrypoint} from 'lakutata/com/entrypoint'
import {Configurable, Inject} from 'lakutata/decorator/di'
import {randomUUID} from 'node:crypto'
import {mkdir, readFile, rename, writeFile} from 'node:fs/promises'
import path from 'node:path'
import {ClipNotFoundException} from '../exceptions/ClipExceptions.js'
/**
* 复制到剪贴板的一段文本
*/
export type Clip = { id: string, text: string, copiedAt: string, pinned: boolean }
/**
* 读写剪贴板的文本:在应用中是 NW.js 的剪贴板,在测试中是一个替身
*/
export type ClipboardAccess = { read(): string, write(text: string): void }
/**
* 剪贴板历史:复制过的文本,最新的在前,保存在文件中。NW.js 不会通知剪贴板的变化,所以组件定时读取剪贴板。历史的变化会推送给
* 窗口(clips.changed),并通知其他组件(changed)
*/
export class History extends Component {
/**
* 向窗口推送事件
*/
@Inject('entrypoint')
protected readonly entrypoint: Entrypoint
/**
* 读写的剪贴板
*/
@Configurable(DTO.Object({read: DTO.Function().required(), write: DTO.Function().required()}).required())
protected readonly clipboard: ClipboardAccess
/**
* 历史文件
*/
@Configurable(DTO.String().required())
protected readonly file: string
/**
* 读取剪贴板的间隔,单位为毫秒
*/
@Configurable(DTO.Number().integer().min(10).optional().default(500))
protected readonly interval: number
/**
* 保留的剪贴项数量,不算置顶的
*/
@Configurable(DTO.Number().integer().min(1).optional().default(50))
protected readonly limit: number
#clips: Clip[] = []
//最后一次从剪贴板读到的文本:它发生变化就是一次复制
#last: string = ''
#timer: NodeJS.Timeout | undefined
//文件的写入,一个接一个进行
#saved: Promise<void> = Promise.resolve()
/**
* 从文件读取历史,然后定时读取剪贴板:应用启动时剪贴板里已有的内容不算复制
*/
protected async init(): Promise<void> {
this.#clips = await readFile(this.file, 'utf8').then((text: string): Clip[] => JSON.parse(text), (): Clip[] => [])
this.#last = this.clipboard.read()
this.#timer = setInterval((): void => this.read(), this.interval)
}
protected async destroy(): Promise<void> {
clearInterval(this.#timer)
await this.#saved
}
/**
* 读取剪贴板:与上次读到的不同的文本就是一次复制
*/
protected read(): void {
const text: string = this.clipboard.read()
if (!text.trim() || text === this.#last) return
this.#last = text
this.add(text)
}
/**
* 把文本放到历史的最前面:再次复制的文本会移到最前,超出上限的最旧的未置顶剪贴项会被丢弃
* @param text
*/
protected add(text: string): Clip {
const clip: Clip = this.#clips.find((item: Clip): boolean => item.text === text) ?? {id: randomUUID(), text: text, copiedAt: '', pinned: false}
clip.copiedAt = new Date().toISOString()
let kept: number = 0
this.#clips = [clip, ...this.#clips.filter((item: Clip): boolean => item !== clip)].filter((item: Clip): boolean => item.pinned || ++kept <= this.limit)
this.changed()
return clip
}
/**
* 保存历史并通知它已变化:先写到旁边的文件,再重命名过去,这样文件永远不会只写了一半
*/
protected changed(): void {
const json: string = JSON.stringify(this.#clips)
this.#saved = this.#saved.then(async (): Promise<void> => {
await mkdir(path.dirname(this.file), {recursive: true})
await writeFile(`${this.file}.tmp`, json)
await rename(`${this.file}.tmp`, this.file)
})
this.entrypoint.broadcast('clips.changed')
this.emit('changed')
}
/**
* 剪贴项:置顶的在前,然后是最新的;有查询词时只返回文本包含它的(不区分大小写)
* @param query
*/
public list(query?: string): Clip[] {
const lowered: string | undefined = query?.toLowerCase()
return this.#clips
.filter((clip: Clip): boolean => !lowered || clip.text.toLowerCase().includes(lowered))
.sort((a: Clip, b: Clip): number => Number(b.pinned) - Number(a.pinned) || b.copiedAt.localeCompare(a.copiedAt))
.map((clip: Clip): Clip => ({...clip}))
}
/**
* 历史中的一条剪贴项
* @param id
*/
protected find(id: string): Clip {
const clip: Clip | undefined = this.#clips.find((item: Clip): boolean => item.id === id)
if (!clip) throw new ClipNotFoundException('The clip {0} is not in the history', id)
return clip
}
/**
* 把剪贴项再次复制到剪贴板:它会移到最前
* @param id
*/
public copy(id: string): Clip {
const {text} = this.find(id)
this.#last = text
this.clipboard.write(text)
return {...this.add(text)}
}
/**
* 置顶或取消置顶剪贴项:置顶的剪贴项会一直保留,不管之后复制了什么
* @param id
* @param pinned
*/
public pin(id: string, pinned: boolean): Clip {
const clip: Clip = this.find(id)
clip.pinned = pinned
this.changed()
return {...clip}
}
/**
* 删除一条剪贴项
* @param id
*/
public remove(id: string): void {
const clip: Clip = this.find(id)
this.#clips = this.#clips.filter((item: Clip): boolean => item !== clip)
this.changed()
}
/**
* 删除置顶以外的所有剪贴项
*/
public clear(): void {
this.#clips = this.#clips.filter((clip: Clip): boolean => clip.pinned)
this.changed()
}
}- 组件只创建一次,由整个应用共用(见 Objects):剪贴项由它保存,因为剪贴项要跨越多次请求保留,而控制器只在一次请求中存在(见 Controllers 第 2 节)。
- 组件对 NW.js 一无所知,它用到的东西都由选项提供(见 Injection):
clipboard是读写剪贴板文本的两个函数(在应用中是 NW.js 的剪贴板,见第 8 节;在测试中是一个变量,见第 11 节),file是保存历史的文件。 - NW.js 不会通知剪贴板的变化,所以
init()每隔interval毫秒读取一次剪贴板。与上次读到的不同的文本就是一次复制;应用启动时剪贴板里已有的文本不算,copy()自己写入的文本也不算。 - 再次复制的文本会移到最前,而不是再加一条。超过
limit条时,最旧的剪贴项会被丢弃,置顶的除外。 - 每次变化都会保存文件并发出通知:
this.entrypoint.broadcast()向窗口推送clips.changed(见 ServiceCalls 第 5 节),this.emit('changed')通知其他组件,比如托盘(见第 6 节)。文件先写到旁边的临时文件(history.json.tmp),再重命名覆盖原文件,这样即使程序崩溃,文件也不会只写了一半;写入按顺序进行,应用停止时destroy()会等最后一次写入完成。
5. 控制器
packages/app/src/controllers/ClipController.ts
import {Controller} from 'lakutata/com/entrypoint'
import {ServiceAction} from 'lakutata/decorator/ctrl'
import {Inject} from 'lakutata/decorator/di'
import {type Clip, History} from '../components/History.js'
import {ClipIdDTO, PinClipDTO, SearchClipsDTO} from '../dtos/ClipDTO.js'
/**
* 历史的动作,由窗口调用:window.ipc.invoke({cmd: 'clips.list'})
*/
export class ClipController extends Controller {
@Inject('history')
protected readonly history: History
@ServiceAction({cmd: 'clips.list'}, SearchClipsDTO)
public async list(input: SearchClipsDTO): Promise<Clip[]> {
return this.history.list(input.query)
}
@ServiceAction({cmd: 'clips.copy'}, ClipIdDTO)
public async copy(input: ClipIdDTO): Promise<Clip> {
return this.history.copy(input.id)
}
@ServiceAction({cmd: 'clips.pin'}, PinClipDTO)
public async pin(input: PinClipDTO): Promise<Clip> {
return this.history.pin(input.id, input.pinned)
}
@ServiceAction({cmd: 'clips.delete'}, ClipIdDTO)
public async remove(input: ClipIdDTO): Promise<void> {
this.history.remove(input.id)
}
@ServiceAction({cmd: 'clips.clear'})
public async clear(): Promise<void> {
this.history.clear()
}
}每个 @ServiceAction 由一条消息调用:{cmd: 'clips.pin', id: '…', pinned: true} 会以 {id: '…', pinned: true} 调用 pin(),输入由 PinClipDTO 校验(见 ServiceCalls 第 2 节)。动作都很短:实际工作由控制器注入的 history 组件完成。没有返回值的动作(比如 remove())会返回 {success: true, error: null}。
6. 窗口和托盘
应用常驻托盘:关闭窗口只是把它隐藏,从托盘菜单可以重新显示窗口或退出应用。模板中有两个文件要修改:窗口组件和 NW.js API 的类型声明;托盘是一个新组件。
packages/app/src/components/MainWindow.ts
import {Application, Component, DTO} from 'lakutata'
import {Configurable, Inject} from 'lakutata/decorator/di'
/**
* 应用的窗口:它的宿主页面(packages/app/host)把用户界面放在 webview 中,与 Node.js 和 NW.js 隔离(用户界面只能通过宿主页面
* 的消息调用应用的动作)。关闭窗口只是把它隐藏:应用在托盘中继续读取剪贴板
*/
export class MainWindow extends Component {
@Inject(Application)
protected readonly app: Application
@Configurable(DTO.Number().optional().default(420))
protected readonly width: number
@Configurable(DTO.Number().optional().default(640))
protected readonly height: number
/**
* 开发时用户界面的 URL,否则使用构建好的文件
*/
@Configurable(DTO.String().optional())
protected readonly ui?: string
#window: nw.Window | undefined
protected async init(): Promise<void> {
//页面是应用中的文件:应用的资源(lakutata.resources)在它的 resources 目录中,用户界面的路径相对于宿主页面
const query: URLSearchParams = new URLSearchParams({ui: this.ui ?? '../ui/index.html'})
//开发时打开用户界面的 DevTools(用户界面由 Vite 提供)
if (this.ui) query.set('devtools', 'true')
const window: nw.Window = await new Promise((resolve: (window: nw.Window) => void): void => nw.Window.open(`resources/host/index.html?${query}`, {
title: this.app.appName,
width: this.width,
height: this.height,
//窗口的图标(Windows、Linux;macOS 的窗口显示应用的图标)
icon: 'resources/logo.png'
}, resolve))
this.#window = window
//关闭窗口时把它隐藏:应用从托盘退出,或者在被要求退出时退出(macOS 上的 Cmd+Q)
window.on('close', (reason?: 'quit'): unknown => reason === 'quit' ? this.app.exit(0) : window.hide())
//再次启动应用时,把窗口重新显示出来
nw.App.on('open', (): void => this.show())
}
/**
* 重新显示窗口,放到其他窗口前面
*/
public show(): void {
this.#window?.show()
this.#window?.restore()
this.#window?.focus()
}
protected async destroy(): Promise<void> {
this.#window?.removeAllListeners('close').close(true)
}
}- 窗口打开模板的宿主页面(
packages/app/host),宿主页面把用户界面放在 webview 中。用户界面与 Node.js 和 NW.js 隔离运行:它只能通过宿主页面的消息访问应用,相当于 Electron 中预加载脚本的角色。 - NW.js 通过
close事件要求窗口关闭;监听了这个事件后,窗口只有通过close(true)才会关闭。这里的监听器把窗口隐藏,除非应用正在退出(macOS 上的 Cmd+Q,这时监听器会收到'quit')。应用停止时,destroy()移除监听器并关闭窗口。 show()把窗口重新显示出来,托盘菜单会调用它,再次启动应用时也会调用它(nw.App.on('open'):NW.js 只运行应用的一个实例)。
packages/app/src/components/Tray.ts
import {Application, Component} from 'lakutata'
import {Inject} from 'lakutata/decorator/di'
import {type Clip, History} from './History.js'
import {MainWindow} from './MainWindow.js'
/**
* 托盘菜单中剪贴项标签的最大长度
*/
const LABEL: number = 40
/**
* 应用在系统托盘中的图标(macOS 的菜单栏、Windows 的通知区域):它的菜单可以再次复制最近的剪贴项、显示窗口或退出应用
*/
export class Tray extends Component {
@Inject(Application)
protected readonly app: Application
@Inject('history')
protected readonly history: History
@Inject('window')
protected readonly window: MainWindow
#tray: nw.Tray | undefined
protected async init(): Promise<void> {
//macOS 的菜单栏按图标的原始尺寸显示,不会缩小:所以这里显示应用的名称来代替图标(22 像素、只有黑色和透明的模板图片可以放得下)
this.#tray = new nw.Tray(process.platform === 'darwin' ? {title: this.app.appName} : {icon: 'resources/logo.png', tooltip: this.app.appName})
this.update()
this.history.on('changed', (): void => this.update())
}
/**
* 用最近的剪贴项重新生成菜单
*/
protected update(): void {
const menu: nw.Menu = new nw.Menu()
for (const clip of this.history.list().slice(0, 5)) {
menu.append(new nw.MenuItem({label: this.label(clip), click: (): unknown => this.history.copy(clip.id)}))
}
menu.append(new nw.MenuItem({type: 'separator'}))
menu.append(new nw.MenuItem({label: 'Show the history', click: (): void => this.window.show()}))
menu.append(new nw.MenuItem({label: 'Quit', click: (): unknown => this.app.exit(0)}))
this.#tray!.menu = menu
}
/**
* 剪贴项的标签:它的第一行,过长时截短
* @param clip
*/
protected label(clip: Clip): string {
const line: string = clip.text.trim().split('\n')[0]
return `${clip.pinned ? '★ ' : ''}${line.length > LABEL ? `${line.slice(0, LABEL - 1)}…` : line}`
}
protected async destroy(): Promise<void> {
this.#tray?.remove()
}
}- 托盘菜单列出最近的五条剪贴项,选中一条就把它再次复制到剪贴板。每当历史发出
changed,组件就重新生成菜单。 - Quit 调用
this.app.exit(0):应用正常停止,组件依次销毁(见 Application 第 3 节),托盘图标被移除,窗口被关闭。 - 在 Windows 上,通知区域会把应用的图标缩小显示。macOS 的菜单栏按图标的原始尺寸显示(
logo.png有 512 像素),所以那里显示应用的名称来代替图标。NW.js 建议在后台脚本中创建托盘,因为后台脚本和应用存在得一样久:页面中创建的托盘在页面跳转后就不再工作。
模板的类型声明描述了它用到的 NW.js API。这里加上了窗口的 close 事件和 hide()、nw.App.dataPath、剪贴板、托盘及其菜单:
packages/app/src/nw.d.ts
/**
* 应用用到的 NW.js API(后台脚本中的全局对象 nw):https://docs.nwjs.io
*/
declare namespace nw {
/**
* NW.js 的窗口
*/
interface Window {
on(event: 'closed' | 'loaded' | 'focus' | 'blur' | 'minimize' | 'restore', listener: () => void): this
/**
* 窗口被要求关闭:由用户关闭,或在应用退出时(macOS 上的 Cmd+Q)带着 quit 参数。监听了这个事件后,窗口只有通过
* close(true) 才会关闭
* @param event
* @param listener
*/
on(event: 'close', listener: (reason?: 'quit') => void): this
removeAllListeners(event?: string): this
show(): void
hide(): void
focus(): void
restore(): void
/**
* 关闭窗口:传 true 时不触发 close 事件
* @param force
*/
close(force?: boolean): void
}
/**
* 窗口的选项:https://docs.nwjs.io/References/Manifest%20Format/#window-subfields
*/
interface WindowOptions {
title?: string
width?: number
height?: number
icon?: string
show?: boolean
[option: string]: unknown
}
/**
* 系统剪贴板:https://docs.nwjs.io/References/Clipboard/
*/
interface Clipboard {
get(type?: 'text' | 'html' | 'rtf' | 'png' | 'jpeg'): string
set(data: string, type?: 'text' | 'html' | 'rtf' | 'png' | 'jpeg'): void
}
/**
* 菜单项:https://docs.nwjs.io/References/MenuItem/
*/
class MenuItem {
constructor(options: {label?: string, type?: 'normal' | 'separator', enabled?: boolean, click?: () => void})
}
/**
* 菜单:https://docs.nwjs.io/References/Menu/
*/
class Menu {
append(item: MenuItem): void
}
/**
* 系统托盘中的图标(macOS 的菜单栏、Windows 的通知区域):https://docs.nwjs.io/References/Tray/
*/
class Tray {
constructor(options: {title?: string, tooltip?: string, icon?: string})
menu: Menu
remove(): void
}
const App: {
/**
* 应用的参数,位于应用目录之后
*/
readonly argv: string[]
/**
* 应用的数据目录,位于用户的数据目录中
*/
readonly dataPath: string
/**
* 退出应用
*/
quit(): void
/**
* 应用再次启动:它的命令行交给第一个实例
* @param event
* @param listener
*/
on(event: 'open', listener: (commandLine: string) => void): void
}
const Window: {
/**
* 打开窗口:URL 相对于应用的目录
* @param url
* @param options
* @param callback
*/
open(url: string, options: WindowOptions, callback: (window: nw.Window) => void): void
}
const Clipboard: {
/**
* 剪贴板
*/
get(): Clipboard
}
}7. 应用的选项
packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints, httpServiceEntrypoint} from 'lakutata/com/entrypoint'
import {nwjsServiceEntrypoint} from 'lakutata/adapter/nwjs'
import path from 'node:path'
import {type ClipboardAccess, History} from '../components/History.js'
import {MainWindow} from '../components/MainWindow.js'
import {Tray} from '../components/Tray.js'
import {ClipController} from '../controllers/ClipController.js'
export type ConfigOptions = {
/**
* 读写的剪贴板,在应用中是 NW.js 的剪贴板(测试传入自己的)
*/
clipboard: ClipboardAccess
/**
* 应用的数据目录,历史保存在这里
*/
dataPath: string
/**
* 读取剪贴板的间隔,单位为毫秒(默认 500)
*/
interval?: number
/**
* 保留的剪贴项数量,不算置顶的(默认 50)
*/
limit?: number
/**
* 开发时用户界面的 URL(Vite 的开发服务器),否则使用构建好的文件
*/
ui?: string
mode?: 'development' | 'production'
/**
* 是否打开窗口和托盘图标,默认为 true(测试在没有它们的情况下提供动作)
*/
window?: boolean
/**
* 放置动作桥接对象的全局对象,默认是 NW.js 的(测试传入自己的)
*/
global?: object
/**
* 开发用的 HTTP 入口的端口,默认没有。工具(Postman、curl)用 POST / 和消息(模式和输入)调用动作,用 GET /events
* 接收推送给它们的事件(Server-Sent Events),并在 id 请求头(或 id 查询参数)中给出客户端 id
*/
devPort?: number
}
/**
* 应用的选项
* @param options
*/
export function config(options: ConfigOptions): ApplicationOptions {
return {
id: 'clips',
name: 'Clips',
mode: options.mode ?? 'development',
components: {
entrypoint: buildEntrypoints({
controllers: [ClipController],
//用户界面通过窗口的宿主页面调用动作:window.ipc.invoke({cmd: 'clips.list'})
service: [
nwjsServiceEntrypoint({global: options.global}),
...options.devPort === undefined ? [] : [httpServiceEntrypoint({port: options.devPort})]
]
}),
history: {
class: History,
clipboard: options.clipboard,
file: path.join(options.dataPath, 'history.json'),
interval: options.interval,
limit: options.limit
},
...(options.window === false ? {} : {
window: {
class: MainWindow,
ui: options.ui
},
tray: {
class: Tray
}
})
},
//先提供动作,窗口再加载用户界面;历史从一开始就读取剪贴板,窗口隐藏时也一样
bootstrap: options.window === false ? ['entrypoint', 'history'] : ['entrypoint', 'history', 'window', 'tray']
}
}history 的选项就是组件的选项:剪贴板、文件(数据目录中的 history.json),以及间隔和上限,这两项为 undefined 时使用默认值。没有窗口时(window: false,即测试中),也没有托盘:需要 NW.js 的组件都不创建,动作通过测试传入的全局对象提供。
8. 后台脚本
packages/app/src/App.ts
import {Application, type ILogger} from 'lakutata'
import {config} from './config/Config.js'
/**
* 开发时用户界面的 URL(--ui=<url>,Vite 的开发服务器),由 lakutata nwjs run 传入
*/
const ui: string | undefined = nw.App.argv.find((argument: string): boolean => argument.startsWith('--ui='))?.slice('--ui='.length)
/**
* 开发时应用运行在开发用的 NW.js 中(lakutata nwjs run:sdk 版本,带 DevTools),安装后运行在普通版本中
*/
const development: boolean = process.versions['nw-flavor'] === 'sdk'
/**
* 通过 NW.js 访问的系统剪贴板
*/
const clipboard: nw.Clipboard = nw.Clipboard.get()
//NW.js 只运行应用的一个实例:再次启动的实例会退出,第一个实例把窗口放到最前面
Application
.run(config({
clipboard: {read: (): string => clipboard.get('text'), write: (text: string): void => clipboard.set(text, 'text')},
//NW.js 为应用提供的数据目录,位于用户的数据目录中
dataPath: nw.App.dataPath,
ui: ui,
mode: development ? 'development' : 'production',
//开发时把动作提供给工具(Postman、curl),安装后的应用不提供
devPort: development ? Number(process.env.DEV_PORT ?? 9000) : undefined
}))
.onLaunched((app: Application, log: ILogger): void => log.info('%s started in %s mode', app.appName, app.mode()))
.onFatalException((error: Error, log: ILogger): void => {
log.error('The application cannot start: %s', error.message)
process.exit(1)
})模板的 App.ts 中加了两个选项:clipboard 是按文本读写的 NW.js 剪贴板(nw.Clipboard.get()),dataPath 是 NW.js 给应用的数据目录(nw.App.dataPath)。这个目录以 packages/app/package.json 中 lakutata.installer 的 appId(clips)命名,开发时和安装后都一样:macOS 上是 ~/Library/Application Support/clips/Default,Windows 上是 %LOCALAPPDATA%\clips,Linux 上是 ~/.config/clips。
模板的宿主页面和桥接代码(packages/app/host、packages/ui/src/ipc.ts)保持不变:它们给用户界面提供应用的动作和事件,不提供 NW.js 或 Node.js 的其他任何东西。
9. 用户界面
packages/ui/src/App.tsx
import {useCallback, useEffect, useState} from 'react'
/**
* 历史中的一条剪贴项,即动作返回的格式
*/
type Clip = { id: string, text: string, copiedAt: string, pinned: boolean }
export function App() {
const [query, setQuery] = useState<string>('')
const [clips, setClips] = useState<Clip[]>([])
const [failure, setFailure] = useState<string>('')
const load = useCallback((): void => {
window.ipc.invoke<Clip[]>({cmd: 'clips.list', query: query || undefined})
.then((loaded: Clip[]): void => setClips(loaded))
.catch((error: Error): void => setFailure(error.message))
}, [query])
useEffect((): () => void => {
load()
//历史变化了:在这里或从托盘复制了内容
window.ipc.on('clips.changed', load)
return (): void => {
window.ipc.off('clips.changed', load)
}
}, [load])
/**
* 调用历史的动作,历史会推送它的变化
* @param data
*/
const act = (data: Record<string, unknown>): void => {
window.ipc.invoke(data).catch((error: Error): void => setFailure(error.message))
}
return (
<main>
<input type="search" placeholder="Search the history" value={query} onChange={(event): void => setQuery(event.target.value)}/>
<button onClick={(): void => act({cmd: 'clips.clear'})}>Clear</button>
{failure && <p role="alert">{failure}</p>}
<ul>
{clips.map((clip: Clip) => (
<li key={clip.id}>
<pre title={new Date(clip.copiedAt).toLocaleString()}>{clip.text}</pre>
<button onClick={(): void => act({cmd: 'clips.copy', id: clip.id})}>Copy</button>
<button onClick={(): void => act({cmd: 'clips.pin', id: clip.id, pinned: !clip.pinned})}>{clip.pinned ? 'Unpin' : 'Pin'}</button>
<button onClick={(): void => act({cmd: 'clips.delete', id: clip.id})}>Delete</button>
</li>
))}
</ul>
</main>
)
}window.ipc是模板的桥接对象(packages/ui/src/ipc.ts):invoke()调用动作,返回它的payload或抛出它的错误;on()和off()监听应用推送的事件。- 每次收到
clips.changed都重新加载列表,不管变化来自哪里:在其他程序中复制、窗口中的操作,还是在托盘菜单中选了一条剪贴项。 - 按钮不直接修改列表:历史推送它的变化,列表随之更新。
10. 运行
npm run dev这个命令启动 Vite 的开发服务器和 NW.js:开发服务器提供用户界面,packages/ui 有变化时会重新加载它;NW.js 的窗口加载用户界面并打开它的 DevTools。应用会输出日志 Clips started in development mode。在任何程序中复制几段文本:它们会出现在窗口的最上面,也会出现在托盘菜单中(macOS 菜单栏上显示名称 Clips)。
开发时,动作也通过 HTTP 在 9000 端口(DEV_PORT)提供,可以用 curl 或 Postman 试用:POST / 发送消息,GET /events 以 Server-Sent Events 的形式接收某个客户端的事件(见 ServiceCalls 第 4 节)。先复制 git log --oneline -5,再复制 https://docs.nwjs.io/References/Tray/,然后在另一个终端中运行:
curl -N 'localhost:9000/events?id=me' &
# event: client
# data: ["me"]
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.list"}'
# {"success":true,"error":null,"payload":[{"id":"89994a65-872a-44f1-bdf2-d44d2ead2748","text":"https://docs.nwjs.io/References/Tray/","copiedAt":"2026-11-03T15:29:26.787Z","pinned":false},{"id":"7de7167d-cfaa-4861-807c-f65ea10b97fc","text":"git log --oneline -5","copiedAt":"2026-11-03T15:29:25.786Z","pinned":false}]}
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.pin", "id": "7de7167d-cfaa-4861-807c-f65ea10b97fc", "pinned": true}'
# {"success":true,"error":null,"payload":{"id":"7de7167d-cfaa-4861-807c-f65ea10b97fc","text":"git log --oneline -5","copiedAt":"2026-11-03T15:29:25.786Z","pinned":true}}
# event: clips.changed
# data: []
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.list", "query": "TRAY"}'
# {"success":true,"error":null,"payload":[{"id":"89994a65-872a-44f1-bdf2-d44d2ead2748","text":"https://docs.nwjs.io/References/Tray/","copiedAt":"2026-11-03T15:29:26.787Z","pinned":false}]}
curl -X POST localhost:9000/ -H 'Content-Type: application/json' -d '{"cmd": "clips.copy", "id": "00000000-0000-4000-8000-000000000000"}'
# {"success":false,"error":{…,"message":"The clip 00000000-0000-4000-8000-000000000000 is not in the history","errMsg":"The clip 00000000-0000-4000-8000-000000000000 is not in the history","err":"ClipNotFoundException","errno":"E_CLIP_NOT_FOUND"},"payload":null}窗口立即显示了置顶,因为事件也推送到了窗口。错误带有异常的全部属性,包括调用栈(这里省略了)。
11. 测试
packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {mkdtempSync, readFileSync, rmSync} from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import {Application} from 'lakutata'
import {NWJS_SERVICE_BRIDGE, type NwjsServiceBridge} from 'lakutata/adapter/nwjs'
import type {Clip} from '../components/History.js'
import {config} from '../config/Config.js'
/**
* 测试用的剪贴板文本,应用在没有 NW.js 的情况下读写它
*/
let copied: string = ''
/**
* 测试的全局对象:通过放在它上面的桥接对象调用动作,与窗口的宿主页面调用方式相同
*/
const shared: Record<symbol, unknown> = {}
/**
* 像用户界面那样调用动作(window.ipc.invoke)
* @param data
*/
function invoke(data: Record<string, unknown>): Promise<any> {
return (shared[Symbol.for(NWJS_SERVICE_BRIDGE)] as NwjsServiceBridge).invoke(data, 'page')
}
/**
* 历史中的文本,按历史的顺序
*/
async function texts(): Promise<string[]> {
return (await invoke({cmd: 'clips.list'})).payload.map((clip: Clip): string => clip.text)
}
/**
* 像其他程序那样把文本复制到剪贴板,并等历史读到它:即最新的未置顶剪贴项
* @param text
*/
async function copy(text: string): Promise<void> {
copied = text
const latest = async (): Promise<string | undefined> => (await invoke({cmd: 'clips.list'})).payload.find((clip: Clip): boolean => !clip.pinned)?.text
for (let attempt: number = 0; await latest() !== text; attempt++) {
if (attempt === 100) throw new Error(`${text} was not read from the clipboard`)
await new Promise((resolve): unknown => setTimeout(resolve, 10))
}
}
describe('Clips', (): void => {
let app: Application
let data: string
const pushed: string[] = []
before(async (): Promise<void> => {
data = mkdtempSync(path.join(os.tmpdir(), 'clips-'))
copied = 'On the clipboard before the application starts'
app = await new Promise<Application>((resolve: (app: Application) => void): void => {
Application.run(config({
clipboard: {read: (): string => copied, write: (text: string): void => void (copied = text)},
dataPath: data,
interval: 10,
limit: 3,
window: false,
global: shared
})).onLaunched((launched: Application): void => resolve(launched))
})
//页面订阅应用的事件
const bridge: NwjsServiceBridge = shared[Symbol.for(NWJS_SERVICE_BRIDGE)] as NwjsServiceBridge
bridge.subscribe('page', (event: string): unknown => pushed.push(event))
})
after(async (): Promise<void> => {
await app?.dispose()
rmSync(data, {recursive: true, force: true})
})
it('keeps the texts copied, the latest first, but not what the clipboard held at the start', async (): Promise<void> => {
await copy('git status')
await copy('npm test')
assert.deepStrictEqual(await texts(), ['npm test', 'git status'])
//窗口收到了通知
assert.ok(pushed.includes('clips.changed'))
})
it('moves a text copied again to the top, and drops the oldest clips beyond the limit', async (): Promise<void> => {
await copy('git status')
await copy('ls -la')
await copy('pwd')
assert.deepStrictEqual(await texts(), ['pwd', 'ls -la', 'git status'])
})
it('keeps the pinned clips first, beyond the limit', async (): Promise<void> => {
const [, , oldest] = (await invoke({cmd: 'clips.list'})).payload
assert.strictEqual((await invoke({cmd: 'clips.pin', id: oldest.id, pinned: true})).payload.pinned, true)
await copy('make')
await copy('make install')
await copy('make clean')
assert.deepStrictEqual(await texts(), ['git status', 'make clean', 'make install', 'make'])
})
it('copies a clip to the clipboard again', async (): Promise<void> => {
const clips: Clip[] = (await invoke({cmd: 'clips.list', query: 'INSTALL'})).payload
assert.deepStrictEqual(clips.map((clip: Clip): string => clip.text), ['make install'])
await invoke({cmd: 'clips.copy', id: clips[0].id})
assert.strictEqual(copied, 'make install')
assert.deepStrictEqual(await texts(), ['git status', 'make install', 'make clean', 'make'])
})
it('removes a clip, then all of them but the pinned ones', async (): Promise<void> => {
const [, second] = (await invoke({cmd: 'clips.list'})).payload
assert.strictEqual((await invoke({cmd: 'clips.delete', id: second.id})).success, true)
assert.deepStrictEqual(await texts(), ['git status', 'make clean', 'make'])
await invoke({cmd: 'clips.clear'})
assert.deepStrictEqual(await texts(), ['git status'])
})
it('keeps the history in its file', async (): Promise<void> => {
await new Promise((resolve): unknown => setTimeout(resolve, 50))
const saved: Clip[] = JSON.parse(readFileSync(path.join(data, 'history.json'), 'utf8'))
assert.deepStrictEqual(saved.map((clip: Clip): string => clip.text), ['git status'])
})
it('refuses a clip that is not in the history, and an invalid input', async (): Promise<void> => {
const missing = await invoke({cmd: 'clips.copy', id: '00000000-0000-4000-8000-000000000000'})
assert.strictEqual(missing.success, false)
assert.strictEqual(missing.error.errno, 'E_CLIP_NOT_FOUND')
const invalid = await invoke({cmd: 'clips.pin', id: 'first'})
assert.strictEqual(invalid.success, false)
assert.match(invalid.error.message, /id/)
})
})测试用 Node.js 运行应用,不需要 NW.js:window: false 不创建窗口和托盘,剪贴板是一个变量,数据目录是一个临时目录。nwjsServiceEntrypoint 把它的桥接对象放在传入的全局对象上(global: shared),在 NW.js 中窗口的宿主页面就是在那里找到它的;测试和窗口一样通过它调用动作,并订阅某个客户端的事件。10 毫秒的间隔和 3 条的上限让测试运行得更快。
npm testapp │ ✔ keeps the texts copied, the latest first, but not what the clipboard held at the start
app │ ✔ moves a text copied again to the top, and drops the oldest clips beyond the limit
app │ ✔ keeps the pinned clips first, beyond the limit
app │ ✔ copies a clip to the clipboard again
app │ ✔ removes a clip, then all of them but the pinned ones
app │ ✔ keeps the history in its file
app │ ✔ refuses a clip that is not in the history, and an invalid input
app │ ℹ pass 7
✔ test ran in appnpm test 先构建各个包(包括用户界面),再运行测试。需要 NW.js 的部分,即系统剪贴板、托盘和窗口,用 npm run dev 实际试用。
12. 打包
npm run package:host构建各个包,然后在dist/installer中生成当前平台的安装包:macOS 上是dmg,Linux 上是deb,Windows 上是nsis安装程序和zip。npm run package:linux在 Docker 中生成 Linux 的安装包,在任何平台上都可以运行。安装包由lakutata package installer根据packages/app/package.json中的lakutata.installer生成(见 Installers)。- 安装后的应用运行在普通版本的 NW.js 中,没有 DevTools:它的模式是
production,也没有 HTTP 入口(只有在lakutata nwjs run的 sdk 版本中才会传入devPort)。 - 应用运行在
packages/app/package.json中lakutata.nwjs指定的 NW.js 版本中;npm run nwjs:use -- <version>可以换成其他版本,比如win7,即 NW.js 0.72,能在 Windows 7、8 和 8.1 上运行的最后一个版本(见 ProjectStructure)。