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..