diff --git a/README.md b/README.md
index c8c0c20..0c9ed26 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ Go to the [Releases](https://github.com/JavBoss/pornboss/releases) page, downloa
### 2. Start the App
- Windows: double-click `pornboss.exe`. If SmartScreen blocks it on first launch, click "More info" and continue.
-- macOS: right-click `pornboss.command` and choose Open. If macOS shows a security warning, continue anyway.
+- macOS: open `Pornboss.app` in the extracted folder. If Gatekeeper blocks it on first launch, right-click the app, choose Open, and continue anyway.
- Linux: run `pornboss`
After launch, Pornboss will try to open your browser automatically. If it does not, open the local address shown in the terminal manually.
diff --git a/README.zh.md b/README.zh.md
index a02858a..28585e2 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -63,7 +63,7 @@ porn manager, jav manager, av manager, jav scraper, jav metadata, adult video ma
### 2. 启动程序
- Windows:双击 `pornboss.exe`;首次运行可能会被smartScreen阻止,点击更多信息->仍要运行
-- macOS:右键 `pornboss.command` 点击打开;如果系统弹出安全警告,仍然选择继续打开
+- macOS:打开解压目录里的 `Pornboss.app`;如果首次启动被系统拦截,右键应用后选择“打开”,再继续运行
- Linux:运行 `pornboss`
启动成功后,程序会自动尝试打开浏览器;如果没有自动打开,可以手动访问终端里显示的本地地址。
diff --git a/cmd/server/main.go b/cmd/server/main.go
index fdade57..b2f1de6 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -26,6 +26,7 @@ import (
"pornboss/internal/manager"
"github.com/gin-gonic/gin"
+ "github.com/mattn/go-isatty"
"gopkg.in/natefinch/lumberjack.v2"
)
@@ -148,8 +149,10 @@ func main() {
actualPort := listener.Addr().(*net.TCPAddr).Port
url := fmt.Sprintf("http://localhost:%d", actualPort)
fmt.Printf("Pornboss启动成功,浏览器访问地址:%s\n", url)
- if err := util.OpenFile(url); err != nil {
- logger.Printf("open browser failed: %v", err)
+ if os.Getenv("PORNBOSS_LAUNCHER") == "" {
+ if err := util.OpenFile(url); err != nil {
+ logger.Printf("open browser failed: %v", err)
+ }
}
logger.Printf("server listening on %s", listener.Addr().String())
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
@@ -246,6 +249,9 @@ func resolveStaticDir(staticDir string) string {
}
func waitForUserExit() {
+ if os.Getenv("PORNBOSS_LAUNCHER") != "" || !isatty.IsTerminal(os.Stdin.Fd()) {
+ return
+ }
fmt.Println("请手动关闭此窗口,或按回车键退出。")
reader := bufio.NewReader(os.Stdin)
if _, err := reader.ReadString('\n'); err != nil {
diff --git a/scripts/cli.sh b/scripts/cli.sh
index 48d0886..b237756 100755
--- a/scripts/cli.sh
+++ b/scripts/cli.sh
@@ -11,7 +11,7 @@ NEED_BUILD=0
if [[ ! -f "$CLI_BIN" ]]; then
NEED_BUILD=1
else
- if find "$CLI_ROOT" -type f \( -name "*.mjs" -o -name "*.js" -o -name "*.json" \) \
+ if find "$CLI_ROOT" -type f \( -name "*.mjs" -o -name "*.js" -o -name "*.json" -o -name "*.swift" \) \
! -path "$CLI_ROOT/node_modules/*" ! -path "$CLI_ROOT/build/*" \
-newer "$CLI_BIN" -print -quit | grep -q .; then
NEED_BUILD=1
diff --git a/scripts/cli/cli.mjs b/scripts/cli/cli.mjs
index 2369af2..71c6045 100755
--- a/scripts/cli/cli.mjs
+++ b/scripts/cli/cli.mjs
@@ -32,6 +32,7 @@ const ROOT_DIR = findRepoRoot(entryDirFromArgv());
const WEB_DIR = path.join(ROOT_DIR, "web");
const INTERNAL_BIN_DIR = path.join(ROOT_DIR, "internal", "bin");
const BIN_DIR = path.join(ROOT_DIR, "bin");
+const MACOS_LAUNCHER_SOURCE = path.join(ROOT_DIR, "scripts", "cli", "macos-launcher.swift");
const PLATFORM_CHOICES = [
{ label: "windows-x86_64", goos: "windows", goarch: "amd64" },
@@ -162,6 +163,29 @@ function runCommand(cmd, args, options = {}) {
});
}
+function runCommandCapture(cmd, args, options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...options });
+ let stdout = "";
+ let stderr = "";
+ child.stdout.on("data", (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on("data", (chunk) => {
+ stderr += chunk;
+ });
+ child.on("error", reject);
+ child.on("close", (code) => {
+ if (code === 0) {
+ resolve(stdout);
+ } else {
+ const detail = stderr.trim() || stdout.trim();
+ reject(new Error(detail || `${cmd} exited with code ${code}`));
+ }
+ });
+ });
+}
+
function commandExists(cmd) {
return new Promise((resolve) => {
const probe = process.platform === "win32" ? "where" : "which";
@@ -275,7 +299,12 @@ async function buildBackendRelease(choice, outDir) {
}
}
- const binName = choice.goos === "windows" ? "pornboss.exe" : "pornboss";
+ const binName =
+ choice.goos === "windows"
+ ? "pornboss.exe"
+ : choice.goos === "darwin"
+ ? "pornboss-server"
+ : "pornboss";
const binPath = path.join(outDir, binName);
const env = {
...process.env,
@@ -314,34 +343,106 @@ async function copyBundledFfmpeg(choice, outDir) {
}
}
-async function createMacCommandLauncher(outDir) {
- const launcherPath = path.join(outDir, "pornboss.command");
- const launcherContent = [
- "#!/bin/bash",
- "set -u",
- 'QUARANTINE_ATTR="com.apple.quarantine"',
- 'SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"',
- 'cd "$SCRIPT_DIR" || exit 1',
- "",
- 'if command -v xattr >/dev/null 2>&1; then',
- ' xattr -dr "$QUARANTINE_ATTR" "$SCRIPT_DIR" >/dev/null 2>&1 || true',
- "fi",
- "",
- '"$SCRIPT_DIR/pornboss" "$@"',
- "status=$?",
- 'if [ "$status" -ne 0 ]; then',
- ' echo',
- ' echo "Pornboss exited with status $status."',
- ' read -r -p "Press Enter to close..." _',
- "fi",
- 'exit "$status"',
- "",
- ].join("\n");
-
- await fsp.writeFile(launcherPath, launcherContent);
+function releaseOutDir(choice, version) {
+ return path.join(ROOT_DIR, "release", `pornboss-${version}-${choice.label}`);
+}
+
+function releaseAppDir(choice, outDir) {
+ if (choice.goos === "darwin") {
+ return path.join(outDir, "Pornboss.app");
+ }
+ return outDir;
+}
+
+function releasePayloadDir(choice, outDir) {
+ if (choice.goos === "darwin") {
+ return path.join(releaseAppDir(choice, outDir), "Contents", "MacOS");
+ }
+ return outDir;
+}
+
+function macLauncherTarget(choice) {
+ if (choice.goarch === "arm64") {
+ return { triple: "arm64-apple-macos11.0", minimumVersion: "11.0" };
+ }
+ return { triple: "x86_64-apple-macos10.13", minimumVersion: "10.13" };
+}
+
+async function compileMacLauncher(choice, appDir) {
+ if (process.platform !== "darwin") {
+ throw new Error("macOS .app 打包需要在 macOS 主机上运行 release");
+ }
+ if (!(await exists(MACOS_LAUNCHER_SOURCE))) {
+ throw new Error(`缺少 macOS launcher 源文件:${MACOS_LAUNCHER_SOURCE}`);
+ }
+ if (!(await commandExists("swiftc"))) {
+ throw new Error("缺少 swiftc,请先安装 Xcode 或 Command Line Tools");
+ }
+ if (!(await commandExists("xcrun"))) {
+ throw new Error("缺少 xcrun,请先安装 Xcode 或 Command Line Tools");
+ }
+
+ const sdkPath = (await runCommandCapture("xcrun", ["--sdk", "macosx", "--show-sdk-path"])).trim();
+ const target = macLauncherTarget(choice);
+ const launcherPath = path.join(appDir, "Contents", "MacOS", "Pornboss");
+ await runCommand(
+ "swiftc",
+ [
+ "-O",
+ "-target",
+ target.triple,
+ "-sdk",
+ sdkPath,
+ MACOS_LAUNCHER_SOURCE,
+ "-o",
+ launcherPath,
+ ],
+ { cwd: ROOT_DIR },
+ );
await fsp.chmod(launcherPath, 0o755);
}
+async function createMacAppBundle(choice, appDir, version) {
+ const contentsDir = path.join(appDir, "Contents");
+ const macosDir = path.join(contentsDir, "MacOS");
+ const resourcesDir = path.join(contentsDir, "Resources");
+ await fsp.mkdir(macosDir, { recursive: true });
+ await fsp.mkdir(resourcesDir, { recursive: true });
+
+ const infoPlistPath = path.join(contentsDir, "Info.plist");
+ const target = macLauncherTarget(choice);
+ const infoPlist = `
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleDisplayName
+ Pornboss
+ CFBundleExecutable
+ Pornboss
+ CFBundleIdentifier
+ com.javboss.pornboss
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ Pornboss
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ ${version}
+ CFBundleVersion
+ ${version}
+ LSMinimumSystemVersion
+ ${target.minimumVersion}
+ NSHighResolutionCapable
+
+
+
+`;
+ await fsp.writeFile(infoPlistPath, infoPlist);
+}
+
async function createZip(outDir, zipPath) {
const hasZip = await commandExists("zip");
if (!hasZip) {
@@ -363,20 +464,23 @@ async function runRelease(choice, version) {
return;
}
- const outDir = path.join(ROOT_DIR, "release", `pornboss-${version}-${choice.label}`);
+ const outDir = releaseOutDir(choice, version);
+ const appDir = releaseAppDir(choice, outDir);
+ const payloadDir = releasePayloadDir(choice, outDir);
await fsp.rm(outDir, { recursive: true, force: true });
- await fsp.mkdir(outDir, { recursive: true });
+ await fsp.mkdir(payloadDir, { recursive: true });
+ if (choice.goos === "darwin") {
+ await createMacAppBundle(choice, appDir, version);
+ console.log("[release] 编译 macOS 原生 launcher");
+ await compileMacLauncher(choice, appDir);
+ }
await buildWeb();
console.log("[release] 复制前端资源");
- await copyDir(path.join(WEB_DIR, "dist"), path.join(outDir, "web", "dist"));
- await buildBackendRelease(choice, outDir);
+ await copyDir(path.join(WEB_DIR, "dist"), path.join(payloadDir, "web", "dist"));
+ await buildBackendRelease(choice, payloadDir);
console.log("[release] 复制 ffmpeg/ffprobe");
- await copyBundledFfmpeg(choice, outDir);
- if (choice.goos === "darwin") {
- console.log("[release] 生成 macOS .command 启动器");
- await createMacCommandLauncher(outDir);
- }
+ await copyBundledFfmpeg(choice, payloadDir);
const zipPath = path.join(
ROOT_DIR,
diff --git a/scripts/cli/macos-launcher.swift b/scripts/cli/macos-launcher.swift
new file mode 100644
index 0000000..f2beb3a
--- /dev/null
+++ b/scripts/cli/macos-launcher.swift
@@ -0,0 +1,357 @@
+import Cocoa
+import Darwin
+import Foundation
+
+private let startupURLPattern = try! NSRegularExpression(
+ pattern: #"https?://localhost:\d+"#,
+ options: []
+)
+
+final class OutputBuffer {
+ private let limit: Int
+ private let lock = NSLock()
+ private var data = Data()
+
+ init(limit: Int) {
+ self.limit = max(limit, 1024)
+ }
+
+ func append(_ chunk: Data) {
+ guard !chunk.isEmpty else {
+ return
+ }
+ lock.lock()
+ defer { lock.unlock() }
+ data.append(chunk)
+ if data.count > limit {
+ data = Data(data.suffix(limit))
+ }
+ }
+
+ func stringValue() -> String {
+ lock.lock()
+ defer { lock.unlock() }
+ if let text = String(data: data, encoding: .utf8) {
+ return text.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return String(decoding: data, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+}
+
+final class LineBuffer {
+ private let lock = NSLock()
+ private var pending = ""
+
+ func append(_ chunk: Data) -> [String] {
+ guard !chunk.isEmpty else {
+ return []
+ }
+ let text: String
+ if let utf8 = String(data: chunk, encoding: .utf8) {
+ text = utf8
+ } else {
+ text = String(decoding: chunk, as: UTF8.self)
+ }
+
+ lock.lock()
+ defer { lock.unlock() }
+
+ pending += text
+ let normalized = pending.replacingOccurrences(of: "\r\n", with: "\n")
+ let parts = normalized.components(separatedBy: "\n")
+ pending = parts.last ?? ""
+ return Array(parts.dropLast()).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ }
+}
+
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ private let outputBuffer = OutputBuffer(limit: 16 * 1024)
+ private let stdoutLineBuffer = LineBuffer()
+ private let stderrLineBuffer = LineBuffer()
+ private var serverProcess: Process?
+ private var outputPipes: [Pipe] = []
+ private var waitingForTerminationReply = false
+ private var serverURL: URL?
+ private var hasAutoOpenedPage = false
+ private var window: NSWindow?
+ private var statusLabel: NSTextField?
+ private var openButton: NSButton?
+
+ func applicationDidFinishLaunching(_ notification: Notification) {
+ installMenu()
+ buildWindow()
+ launchServer()
+ }
+
+ func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
+ true
+ }
+
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ guard let process = serverProcess, process.isRunning else {
+ return .terminateNow
+ }
+ waitingForTerminationReply = true
+ terminateServer(process)
+ return .terminateLater
+ }
+
+ @objc
+ private func quitSelected(_ sender: Any?) {
+ NSApp.terminate(sender)
+ }
+
+ @objc
+ private func openPageSelected(_ sender: Any?) {
+ guard let url = serverURL else {
+ NSSound.beep()
+ return
+ }
+ NSWorkspace.shared.open(url)
+ }
+
+ private func installMenu() {
+ let appName =
+ (Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
+ ?? "Pornboss"
+
+ let mainMenu = NSMenu()
+ let appMenuItem = NSMenuItem()
+ mainMenu.addItem(appMenuItem)
+
+ let appMenu = NSMenu()
+ let quitItem = NSMenuItem(title: "Quit \(appName)", action: #selector(quitSelected(_:)), keyEquivalent: "q")
+ quitItem.target = self
+ appMenu.addItem(quitItem)
+
+ appMenuItem.submenu = appMenu
+ NSApp.mainMenu = mainMenu
+ }
+
+ private func buildWindow() {
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 360, height: 140),
+ styleMask: [.titled, .closable, .miniaturizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.center()
+ window.title = "Pornboss"
+ window.isReleasedWhenClosed = false
+
+ let contentView = NSView(frame: window.contentRect(forFrameRect: window.frame))
+ contentView.translatesAutoresizingMaskIntoConstraints = false
+ window.contentView = contentView
+
+ let statusLabel = NSTextField(labelWithString: "Starting Pornboss...")
+ statusLabel.font = .systemFont(ofSize: 14)
+ statusLabel.alignment = .center
+ statusLabel.translatesAutoresizingMaskIntoConstraints = false
+
+ let openButton = NSButton(title: "Open Page", target: self, action: #selector(openPageSelected(_:)))
+ openButton.bezelStyle = .rounded
+ openButton.isEnabled = false
+ openButton.translatesAutoresizingMaskIntoConstraints = false
+
+ let quitButton = NSButton(title: "Quit", target: self, action: #selector(quitSelected(_:)))
+ quitButton.bezelStyle = .rounded
+ quitButton.translatesAutoresizingMaskIntoConstraints = false
+
+ contentView.addSubview(statusLabel)
+ contentView.addSubview(openButton)
+ contentView.addSubview(quitButton)
+
+ NSLayoutConstraint.activate([
+ statusLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 28),
+ statusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20),
+ statusLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20),
+
+ openButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 24),
+ openButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: -62),
+ openButton.widthAnchor.constraint(equalToConstant: 110),
+
+ quitButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 24),
+ quitButton.centerXAnchor.constraint(equalTo: contentView.centerXAnchor, constant: 62),
+ quitButton.widthAnchor.constraint(equalToConstant: 110),
+ ])
+
+ self.window = window
+ self.statusLabel = statusLabel
+ self.openButton = openButton
+
+ window.makeKeyAndOrderFront(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ private func updateStatus(_ text: String) {
+ statusLabel?.stringValue = text
+ }
+
+ private func handleServerLine(_ line: String) {
+ guard !line.isEmpty else {
+ return
+ }
+ guard serverURL == nil else {
+ return
+ }
+ let range = NSRange(line.startIndex..