之前写过一版 DSH 的 macOS 启动器:一个 shell 脚本包装成的 .app,双击后自动拉起 dsh web 服务再开浏览器。能用,但打开的还是浏览器标签页,混在一堆网页里不够"应用"。

这次升级成原生应用方案,并且把整套方法整理成跨平台教程:

  • macOS:Swift + WKWebView 编译成原生 .app,独立窗口、Dock 图标、断线自动重连
  • Windows:Edge/Chrome --app 模式快捷方式(零依赖),或 WebView2 原生窗口(进阶)
  • 图标:不再手绘,直接从 DSH 源码里提取官方鲸鱼 SVG 渲染,配深蓝渐变圆角背景

最终效果(图标):

DSH Web 应用图标

本文自包含:所有源码完整附上,照抄即可复现;也可以直接把本文链接发给任何 AI agent,让它照着做。


一、原理

DSH 的 Web 界面跑在 http://127.0.0.1:3080。所谓"桌面应用",本质就是一个固定加载这个地址的独立窗口

平台方案窗口技术
macOS编译原生 .appWKWebView
Windows(推荐)快捷方式 + --app= 参数Edge/Chrome 应用模式
Windows(进阶).NET WinFormsWebView2

三种方案都不需要打包网页资源,服务还是那个服务,只是入口变成了双击图标。

二、通用部分:制作图标

2.1 提取官方鲸鱼 SVG

DSH 前端的产物目录里自带 favicon.svg,就是官方鲸鱼 Logo。在本机 DSH 安装目录下找:

1
2
3
4
5
6
npm root -g
# 拼上相对路径:
#   <npm全局根>/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-web-frontend/dist/favicon.svg

mkdir -p dsh-app && cd dsh-app
cp "$(npm root -g)/@deepseek-ai/dsh/node_modules/@deepseek-ai/dsh-web-frontend/dist/favicon.svg" .

Windows 上 npm root -g 输出形如 C:\Users\<你>\AppData\Roaming\npm\node_modules,同样拼上后面的相对路径。找不到的话用 find "$(npm root -g)/@deepseek-ai" -name favicon.svg 定位。

2.2 图标生成脚本(双平台通用)

依赖:Python 3 + Pillow(pip install pillow)。保存为 make_icon.py

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
#!/usr/bin/env python3
"""DSH Web app icon: official DeepSeek Harness whale (from favicon.svg)
on a deep-blue gradient rounded square. 4x supersampled; self-checks at end."""
from PIL import Image, ImageDraw, ImageFilter
import os, re, subprocess, sys

OUT = os.path.dirname(os.path.abspath(__file__))
S = 4096   # supersample canvas
R = 1024   # final master


def lerp(a, b, t):
    return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3))


# ---------- minimal SVG path parser (official favicon uses M/C only) ----------
def parse_svg_path(d):
    tokens = re.findall(r'([MmLlCcQqZzHhVvSsTtAa])|(-?\d*\.?\d+(?:e-?\d+)?)', d)
    tokens = [t[0] or float(t[1]) for t in tokens]
    i = 0

    def num():
        nonlocal i
        v = float(tokens[i]); i += 1
        return v

    def cubic(p0, p1, p2, p3, n=40):
        return [( (1-t)**3*p0[0]+3*(1-t)**2*t*p1[0]+3*(1-t)*t*t*p2[0]+t**3*p3[0],
                  (1-t)**3*p0[1]+3*(1-t)**2*t*p1[1]+3*(1-t)*t*t*p2[1]+t**3*p3[1] )
                for t in (k/n for k in range(1, n+1))]

    def quad(p0, p1, p2, n=30):
        return [( (1-t)**2*p0[0]+2*(1-t)*t*p1[0]+t*t*p2[0],
                  (1-t)**2*p0[1]+2*(1-t)*t*p1[1]+t*t*p2[1] )
                for t in (k/n for k in range(1, n+1))]

    subpaths, cur = [], []
    pos = start = (0, 0); last_c = None; last_letter = 'M'
    while i < len(tokens):
        if isinstance(tokens[i], str):
            c = tokens[i]; i += 1
            if c in 'Zz':
                if cur and cur[-1] != start: cur.append(start)
                if cur: subpaths.append(cur)
                cur = []; pos = start; last_c = None
                continue
        else:
            c = last_letter  # implicit repeat (M -> L)
        rel = c.islower(); C = c.upper()
        last_letter = 'L' if C == 'M' else C
        if C == 'M':
            x, y = num(), num()
            pos = (pos[0]+x, pos[1]+y) if rel else (x, y)
            if cur: subpaths.append(cur)
            cur = [pos]; start = pos; last_c = None
        elif C == 'L':
            x, y = num(), num()
            pos = (pos[0]+x, pos[1]+y) if rel else (x, y)
            cur.append(pos); last_c = None
        elif C == 'H':
            x = num(); pos = ((pos[0]+x) if rel else x, pos[1]); cur.append(pos); last_c = None
        elif C == 'V':
            y = num(); pos = (pos[0], (pos[1]+y) if rel else y); cur.append(pos); last_c = None
        elif C == 'C':
            x1, y1, x2, y2, x, y = num(), num(), num(), num(), num(), num()
            p1 = (pos[0]+x1, pos[1]+y1) if rel else (x1, y1)
            p2 = (pos[0]+x2, pos[1]+y2) if rel else (x2, y2)
            p3 = (pos[0]+x, pos[1]+y) if rel else (x, y)
            cur += cubic(pos, p1, p2, p3); pos = p3; last_c = p2
        elif C == 'S':
            x2, y2, x, y = num(), num(), num(), num()
            p1 = (2*pos[0]-last_c[0], 2*pos[1]-last_c[1]) if last_c else pos
            p2 = (pos[0]+x2, pos[1]+y2) if rel else (x2, y2)
            p3 = (pos[0]+x, pos[1]+y) if rel else (x, y)
            cur += cubic(pos, p1, p2, p3); pos = p3; last_c = p2
        elif C == 'Q':
            x1, y1, x, y = num(), num(), num(), num()
            p1 = (pos[0]+x1, pos[1]+y1) if rel else (x1, y1)
            p2 = (pos[0]+x, pos[1]+y) if rel else (x, y)
            cur += quad(pos, p1, p2); pos = p2; last_c = None
        else:
            raise ValueError("unhandled command " + c)
    if cur: subpaths.append(cur)
    return subpaths


def build_official_whale(canvas, span=0.62):
    """Render the official DSH whale (favicon.svg) white, centered, spanning `span` of canvas."""
    src = open(os.path.join(OUT, "favicon.svg")).read()
    d = re.search(r'\bd="([^"]+)"', src).group(1)
    subpaths = parse_svg_path(d)
    xs = [p[0] for sp in subpaths for p in sp]
    ys = [p[1] for sp in subpaths for p in sp]
    bw, bh = max(xs) - min(xs), max(ys) - min(ys)
    scale = canvas * span / max(bw, bh)
    ox = canvas / 2 - (min(xs) + max(xs)) / 2 * scale
    oy = canvas / 2 - (min(ys) + max(ys)) / 2 * scale - canvas * 0.015

    mask = Image.new("L", (canvas, canvas), 0)
    dr = ImageDraw.Draw(mask)
    for sp in subpaths:
        dr.polygon([(p[0]*scale + ox, p[1]*scale + oy) for p in sp], fill=255)

    # white -> ice-blue vertical gradient inside the whale mask
    g1 = Image.new("RGBA", (1, canvas))
    for y in range(canvas):
        g1.putpixel((0, y), lerp((255, 255, 255), (190, 224, 255), y / canvas) + (255,))
    grad = g1.resize((canvas, canvas))
    return Image.composite(grad, Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)), mask)


def main():
    # background: diagonal navy -> blue -> teal
    bg = Image.new("RGBA", (R, R))
    px = bg.load()
    c1, c2, c3 = (7, 14, 40), (18, 56, 124), (9, 118, 168)
    for y in range(R):
        for x in range(R):
            t = (x + y) / (2 * R)
            c = lerp(c1, c2, t * 2) if t < 0.5 else lerp(c2, c3, (t - 0.5) * 2)
            px[x, y] = c + (255,)

    # rounded-rect mask
    mask = Image.new("L", (R, R), 0)
    ImageDraw.Draw(mask).rounded_rectangle([0, 0, R - 1, R - 1], radius=int(R * 0.2237), fill=255)
    bg.putalpha(mask)

    # whale: 4x supersample -> glow -> composite
    whale_small = build_official_whale(S).resize((R, R), Image.LANCZOS)

    glow = Image.new("RGBA", (S, S), (0, 0, 0, 0))
    ImageDraw.Draw(glow).ellipse([S*0.15, S*0.20, S*0.85, S*0.85], fill=(70, 190, 255, 150))
    glow = glow.filter(ImageFilter.GaussianBlur(S * 0.09)).resize((R, R), Image.LANCZOS)
    glow = Image.composite(glow, Image.new("RGBA", (R, R), (0, 0, 0, 0)), whale_small.split()[3])
    bg = Image.alpha_composite(bg, glow)
    bg = Image.alpha_composite(bg, whale_small)

    bg.save(os.path.join(OUT, "icon_1024.png"))

    # ---- Windows .ico ----
    bg.save(os.path.join(OUT, "AppIcon.ico"),
            sizes=[(16, 16), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)])

    # ---- macOS .icns(仅 macOS 有 iconutil)----
    if sys.platform == "darwin":
        iconset = os.path.join(OUT, "AppIcon.iconset")
        os.makedirs(iconset, exist_ok=True)
        for name, size in {"icon_16x16.png": 16, "icon_16x16@2x.png": 32, "icon_32x32.png": 32,
                           "icon_32x32@2x.png": 64, "icon_128x128.png": 128, "icon_128x128@2x.png": 256,
                           "icon_256x256.png": 256, "icon_256x256@2x.png": 512, "icon_512x512.png": 512,
                           "icon_512x512@2x.png": 1024}.items():
            bg.resize((size, size), Image.LANCZOS).save(os.path.join(iconset, name))
        subprocess.run(["iconutil", "-c", "icns", iconset, "-o",
                        os.path.join(OUT, "AppIcon.icns")], check=True)

    # self-check
    px = bg.load()
    bright = [(x, y) for y in range(R) for x in range(R)
              if px[x, y][3] > 200 and px[x, y][0] > 200 and px[x, y][1] > 200]
    assert len(bright) > 30000, f"whale too small: {len(bright)} bright pixels"
    xs = [p[0] for p in bright]; ys = [p[1] for p in bright]
    print(f"bright pixels: {len(bright)}  whale bbox: x[{min(xs)}..{max(xs)}] y[{min(ys)}..{max(ys)}]")
    print("OK icon_1024.png + AppIcon.ico" + (" + AppIcon.icns" if sys.platform == "darwin" else ""))


if __name__ == "__main__":
    main()

运行:

1
python3 make_icon.py        # Windows 用 python make_icon.py

产物:

文件用途
icon_1024.png1024px 预览图
AppIcon.icnsmacOS 应用图标(仅 macOS 生成)
AppIcon.icoWindows 快捷方式/程序图标(16~256 共 6 档尺寸)

脚本要点:内置了一个极简 SVG path 解析器(支持 M/L/C/S/Q/H/V/Z,官方 favicon 只用到了 M/C/Z),把鲸鱼路径 4 倍超采样渲染成白色渐变鲸鱼,再叠光晕合成到深蓝渐变圆角底板上;末尾自动做像素自检,鲸鱼没画出来会直接 assert 报错。脚本按平台自动决定生成 .icns 还是只出 .ico

三、macOS:编译原生 .app

3.1 依赖

1
2
xcode-select --install   # 提供 swiftc / iconutil,装过可跳过
python3 -m pip install pillow

3.2 应用源码 main.swift

一个完整的原生应用,不到 200 行:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import Cocoa
import WebKit

final class AppDelegate: NSObject, NSApplicationDelegate, WKNavigationDelegate {
    private var window: NSWindow!
    private var webView: WKWebView!
    private var errorView: NSView?
    private var retryTimer: Timer?
    private let homeURL = URL(string: "http://127.0.0.1:3080")!

    func applicationDidFinishLaunching(_ notification: Notification) {
        buildMenu()

        let rect = NSRect(x: 0, y: 0, width: 1280, height: 840)
        window = NSWindow(contentRect: rect,
                          styleMask: [.titled, .closable, .miniaturizable, .resizable],
                          backing: .buffered, defer: false)
        window.title = "DSH Web"
        window.minSize = NSSize(width: 800, height: 560)
        window.setFrameAutosaveName("DSHWebWindow")
        window.center()

        let config = WKWebViewConfiguration()
        webView = WKWebView(frame: rect, configuration: config)
        webView.navigationDelegate = self
        webView.allowsBackForwardNavigationGestures = true
        window.contentView = webView

        window.makeKeyAndOrderFront(nil)
        NSApp.activate(ignoringOtherApps: true)
        loadHome()
    }

    func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }

    // MARK: - Loading

    private func loadHome() {
        hideError()
        var req = URLRequest(url: homeURL)
        req.cachePolicy = .reloadIgnoringLocalCacheData
        req.timeoutInterval = 8
        webView.load(req)
    }

    // MARK: - WKNavigationDelegate

    func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
        stopAutoRetry()
        hideError()
    }

    func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
        showError()
    }

    func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
        showError()
    }

    // MARK: - Error screen (auto-retry while server is not up yet)

    private func showError() {
        if errorView == nil { buildErrorView() }
        errorView?.isHidden = false
        startAutoRetry()
    }

    private func hideError() {
        errorView?.isHidden = true
    }

    private func startAutoRetry() {
        guard retryTimer == nil else { return }
        retryTimer = Timer.scheduledTimer(withTimeInterval: 4, repeats: true) { [weak self] _ in
            self?.loadHome()
        }
    }

    private func stopAutoRetry() {
        retryTimer?.invalidate()
        retryTimer = nil
    }

    private func buildErrorView() {
        guard let content = window.contentView else { return }
        let v = NSView(frame: content.bounds)
        v.autoresizingMask = [.width, .height]
        v.wantsLayer = true
        v.layer?.backgroundColor = NSColor(calibratedRed: 0.05, green: 0.08, blue: 0.17, alpha: 1).cgColor

        let title = NSTextField(labelWithString: "无法连接到 DSH Web")
        title.font = .systemFont(ofSize: 22, weight: .semibold)
        title.textColor = .white

        let sub = NSTextField(labelWithString: "请先启动 dsh 服务(127.0.0.1:3080),正在自动重试,也可以点击下方按钮。")
        sub.font = .systemFont(ofSize: 13)
        sub.textColor = NSColor(white: 0.72, alpha: 1)

        let btn = NSButton(title: "立即重试", target: self, action: #selector(retryTapped))
        btn.bezelStyle = .rounded
        btn.controlSize = .large
        btn.keyEquivalent = "\r"

        let stack = NSStackView(views: [title, sub, btn])
        stack.orientation = .vertical
        stack.alignment = .centerX
        stack.spacing = 14
        stack.translatesAutoresizingMaskIntoConstraints = false
        v.addSubview(stack)
        NSLayoutConstraint.activate([
            stack.centerXAnchor.constraint(equalTo: v.centerXAnchor),
            stack.centerYAnchor.constraint(equalTo: v.centerYAnchor),
        ])
        content.addSubview(v, positioned: .above, relativeTo: webView)
        errorView = v
    }

    @objc private func retryTapped() { loadHome() }

    @objc private func reloadHome(_ sender: Any?) { loadHome() }
    @objc private func goBack(_ sender: Any?) { webView.goBack() }
    @objc private func goForward(_ sender: Any?) { webView.goForward() }

    // MARK: - Menus (Cmd+Q / Cmd+R / Cmd+W / copy-paste)

    private func buildMenu() {
        let mainMenu = NSMenu()

        let appItem = NSMenuItem()
        mainMenu.addItem(appItem)
        let appMenu = NSMenu()
        appMenu.addItem(withTitle: "关于 DSH Web", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "")
        appMenu.addItem(.separator())
        appMenu.addItem(withTitle: "隐藏 DSH Web", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h")
        appMenu.addItem(.separator())
        appMenu.addItem(withTitle: "退出 DSH Web", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
        appItem.submenu = appMenu

        let navItem = NSMenuItem()
        mainMenu.addItem(navItem)
        let navMenu = NSMenu(title: "导航")
        navMenu.addItem(withTitle: "重新加载", action: #selector(reloadHome(_:)), keyEquivalent: "r")
        navMenu.addItem(withTitle: "后退", action: #selector(goBack(_:)), keyEquivalent: "[")
        navMenu.addItem(withTitle: "前进", action: #selector(goForward(_:)), keyEquivalent: "]")
        navMenu.addItem(.separator())
        navMenu.addItem(withTitle: "关闭窗口", action: #selector(NSWindow.performClose(_:)), keyEquivalent: "w")
        navItem.submenu = navMenu

        let editItem = NSMenuItem()
        mainMenu.addItem(editItem)
        let editMenu = NSMenu(title: "编辑")
        editMenu.addItem(withTitle: "撤销", action: Selector(("undo:")), keyEquivalent: "z")
        editMenu.addItem(withTitle: "重做", action: Selector(("redo:")), keyEquivalent: "Z")
        editMenu.addItem(.separator())
        editMenu.addItem(withTitle: "剪切", action: #selector(NSText.cut(_:)), keyEquivalent: "x")
        editMenu.addItem(withTitle: "拷贝", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
        editMenu.addItem(withTitle: "粘贴", action: #selector(NSText.paste(_:)), keyEquivalent: "v")
        editMenu.addItem(withTitle: "全选", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
        editItem.submenu = editMenu

        let winItem = NSMenuItem()
        mainMenu.addItem(winItem)
        let winMenu = NSMenu(title: "窗口")
        winMenu.addItem(withTitle: "最小化", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m")
        winMenu.addItem(withTitle: "缩放", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
        winItem.submenu = winMenu

        NSApp.mainMenu = mainMenu
    }
}

let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.regular)
app.run()

功能:独立原生窗口(记住大小位置)、dsh 服务没起时显示提示页并每 4 秒自动重试(先开应用再启服务也行)、完整菜单快捷键(Cmd+R 重载、Cmd+[/] 前进后退、Cmd+W 关窗、Cmd+Q 退出、复制粘贴)。

3.3 Info.plist

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleName</key>
	<string>DSH Web</string>
	<key>CFBundleDisplayName</key>
	<string>DSH Web</string>
	<key>CFBundleIdentifier</key>
	<string>com.deepseek.dsh-web</string>
	<key>CFBundleExecutable</key>
	<string>DSHWeb</string>
	<key>CFBundlePackageType</key>
	<string>APPL</string>
	<key>CFBundleIconFile</key>
	<string>AppIcon</string>
	<key>CFBundleIconName</key>
	<string>AppIcon</string>
	<key>CFBundleShortVersionString</key>
	<string>1.0</string>
	<key>CFBundleVersion</key>
	<string>1</string>
	<key>LSMinimumSystemVersion</key>
	<string>12.0</string>
	<key>LSApplicationCategoryType</key>
	<string>public.app-category.developer-tools</string>
	<key>LSMultipleInstancesProhibited</key>
	<true/>
	<key>NSHighResolutionCapable</key>
	<true/>
	<key>NSAppTransportSecurity</key>
	<dict>
		<key>NSAllowsLocalNetworking</key>
		<true/>
	</dict>
</dict>
</plist>

⚠️ NSAllowsLocalNetworking 必须有,否则 ATS 可能拦截 http://127.0.0.1 的明文请求,窗口一直空白。

3.4 一键构建 build.sh

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash
# Build "DSH Web.app": native WKWebView wrapper for http://127.0.0.1:3080
set -euo pipefail
cd "$(dirname "$0")"

APP="DSH Web.app"
echo "==> 生成图标"
python3 make_icon.py

echo "==> 编译 Swift"
swiftc -O -o DSHWeb main.swift -framework Cocoa -framework WebKit

echo "==> 组装 bundle"
rm -rf "$APP"
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
cp DSHWeb "$APP/Contents/MacOS/DSHWeb"
cp Info.plist "$APP/Contents/Info.plist"
cp AppIcon.icns "$APP/Contents/Resources/AppIcon.icns"

echo "==> ad-hoc 签名"
codesign --force --deep -s - "$APP"

echo "==> 完成: $(pwd)/$APP"

3.5 构建、安装、验证

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
chmod +x build.sh
./build.sh

# 安装到用户应用目录(Launchpad / 聚焦搜索可见)
cp -R "DSH Web.app" ~/Applications/

# 验证
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3080/   # 期望 200
open ~/Applications/"DSH Web.app"
pgrep -fl DSHWeb                                                  # 有进程即成功

四、Windows:两种方案

方案 A(推荐):Edge/Chrome --app 快捷方式

零编译依赖,效果接近原生应用:独立窗口、无地址栏、独立任务栏图标。

  1. 按第二节生成 AppIcon.ico(Windows 上直接 python make_icon.py)。
  2. 保存下面脚本为 make_shortcut.ps1,与 AppIcon.ico 放同一目录:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# make_shortcut.ps1 — 创建 DSH Web 桌面快捷方式(独立应用窗口)
$edge86  = "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
$edge64  = "C:\Program Files\Microsoft\Edge\Application\msedge.exe"
$chrome  = "C:\Program Files\Google\Chrome\Application\chrome.exe"
$browser = if (Test-Path $edge86) { $edge86 }
           elseif (Test-Path $edge64) { $edge64 }
           elseif (Test-Path $chrome) { $chrome }
           else { throw "未找到 Edge 或 Chrome" }

$ws = New-Object -ComObject WScript.Shell

# 桌面快捷方式
$sc = $ws.CreateShortcut("$env:USERPROFILE\Desktop\DSH Web.lnk")
$sc.TargetPath       = $browser
$sc.Arguments        = "--app=http://127.0.0.1:3080"
$sc.IconLocation     = "$PSScriptRoot\AppIcon.ico"
$sc.WorkingDirectory = $PSScriptRoot
$sc.Description      = "DeepSeek Harness Web"
$sc.Save()

# 开始菜单(可选,便于搜索)
$start = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs"
Copy-Item "$env:USERPROFILE\Desktop\DSH Web.lnk" "$start\DSH Web.lnk" -Force

Write-Host "OK: 已创建快捷方式(浏览器: $browser)"
  1. 运行(如遇执行策略限制):
1
powershell -ExecutionPolicy Bypass -File make_shortcut.ps1
  1. 双击桌面的 DSH Web 即可,可右键快捷方式 →「固定到任务栏」。

方案 B(进阶):WebView2 原生窗口

需要 .NET SDK(Win11 自带 WebView2 运行时):

1
2
3
4
dotnet new winforms -n DshWeb
cd DshWeb
dotnet add package Microsoft.Web.WebView2
copy ..\AppIcon.ico .

Program.cs 改成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
using Microsoft.Web.WebView2.WinForms;
using System.Windows.Forms;

var form = new Form
{
    Text = "DSH Web",
    Width = 1280,
    Height = 840,
    StartPosition = FormStartPosition.CenterScreen,
    Icon = new System.Drawing.Icon("AppIcon.ico")
};
var web = new WebView2 { Dock = DockStyle.Fill };
form.Controls.Add(web);
form.Shown += async (s, e) =>
{
    await web.EnsureCoreWebView2Async();
    web.CoreWebView2.Navigate("http://127.0.0.1:3080");
};
Application.Run(form);

发布为单文件:

1
2
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true
# 产物:bin\Release\net8.0-windows\win-x64\publish\DshWeb.exe

五、验证清单

检查项方法期望
dsh 服务在线访问 http://127.0.0.1:3080/HTTP 200
图标正常看 Dock / 桌面快捷方式深蓝渐变底 + 白色鲸鱼
应用启动双击图标独立窗口打开 DSH Web
服务未启动先开应用再启服务提示页自动重试并成功加载

六、自定义

  • 换端口/地址:macOS 改 main.swift 里的 homeURL;Windows 改快捷方式 --app= 参数,改完分别重跑 build.sh / make_shortcut.ps1
  • 图标鲸鱼大小make_icon.pybuild_official_whale(S, span=0.62)span(0~1)
  • 背景配色make_icon.pyc1, c2, c3 三个 RGB
  • 窗口默认尺寸main.swiftNSRect(... width: 1280, height: 840)

七、常见问题

  • macOS 提示"无法打开,因为无法验证开发者":本机编译 + ad-hoc 签名一般不会有;如果是从别的机器拷贝过来的,先 xattr -dr com.apple.quarantine "DSH Web.app"
  • 窗口空白/打不开:确认 dsh web 已在运行且端口是 3080;macOS 版会自动重试,Windows 快捷方式需手动 Ctrl+R 刷新。
  • favicon.svg 找不到:DSH 版本目录结构可能有差异,用 find "$(npm root -g)/@deepseek-ai" -name favicon.svg 定位。

相比上一版 shell 脚本启动器,这版是真正的独立应用窗口,快捷键、菜单栏、Dock 图标一应俱全;图标也从手搓 SVG 渲染换成了直接复用官方鲸鱼——教训是:先翻翻项目自带的资源,比自己重画省事得多