diff --git a/README.en.md b/README.en.md index e5f0392..7bc1830 100644 --- a/README.en.md +++ b/README.en.md @@ -68,8 +68,8 @@ 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. -- Linux: run `pornboss` +- 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.md b/README.md index 1392fb9..faba72b 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,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/assets/macos/AppIcon.png b/assets/macos/AppIcon.png new file mode 100644 index 0000000..6acf59a Binary files /dev/null and b/assets/macos/AppIcon.png differ diff --git a/cmd/server/main.go b/cmd/server/main.go index 4c6f3e5..2821f7c 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -27,6 +27,7 @@ import ( "pornboss/internal/manager" "github.com/gin-gonic/gin" + "github.com/mattn/go-isatty" "gopkg.in/natefinch/lumberjack.v2" ) @@ -156,8 +157,10 @@ func main() { } else { fmt.Printf("Pornboss started successfully. Open this URL in your browser: %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 { @@ -254,6 +257,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..357e999 100755 --- a/scripts/cli/cli.mjs +++ b/scripts/cli/cli.mjs @@ -32,6 +32,8 @@ 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 MACOS_APP_ICON_PNG = path.join(ROOT_DIR, "assets", "macos", "AppIcon.png"); const PLATFORM_CHOICES = [ { label: "windows-x86_64", goos: "windows", goarch: "amd64" }, @@ -162,6 +164,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 +300,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 +344,192 @@ 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); } +function parseSipsProperties(output) { + const properties = {}; + for (const line of output.split("\n")) { + const trimmed = line.trim(); + const separator = trimmed.indexOf(":"); + if (separator === -1) continue; + const key = trimmed.slice(0, separator).trim(); + const value = trimmed.slice(separator + 1).trim(); + if (key) properties[key] = value; + } + return properties; +} + +async function generateMacAppIcon(outputPath) { + if (!(await exists(MACOS_APP_ICON_PNG))) { + throw new Error(`缺少 macOS app 图标源文件:${MACOS_APP_ICON_PNG}`); + } + if (!(await commandExists("sips"))) { + throw new Error("缺少 sips,无法从 PNG 生成 macOS app 图标"); + } + if (!(await commandExists("iconutil"))) { + throw new Error("缺少 iconutil,无法生成 macOS app 图标"); + } + + const info = parseSipsProperties( + await runCommandCapture("sips", [ + "-g", + "pixelWidth", + "-g", + "pixelHeight", + "-g", + "format", + MACOS_APP_ICON_PNG, + ]), + ); + const width = Number.parseInt(info.pixelWidth ?? "", 10); + const height = Number.parseInt(info.pixelHeight ?? "", 10); + if (info.format !== "png") { + throw new Error(`macOS app 图标必须是 PNG:${MACOS_APP_ICON_PNG}`); + } + if (!Number.isFinite(width) || !Number.isFinite(height) || width !== height) { + throw new Error(`macOS app 图标必须是正方形 PNG:${MACOS_APP_ICON_PNG}`); + } + if (width < 1024) { + throw new Error(`macOS app 图标建议至少 1024x1024,当前为 ${width}x${height}`); + } + + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "pornboss-iconset-")); + const iconsetDir = path.join(tmpDir, "AppIcon.iconset"); + const icon1024 = path.join(iconsetDir, "icon_512x512@2x.png"); + try { + await fsp.mkdir(iconsetDir, { recursive: true }); + await fsp.copyFile(MACOS_APP_ICON_PNG, icon1024); + + const iconSizes = [ + ["16", "16", "icon_16x16.png"], + ["32", "32", "icon_16x16@2x.png"], + ["32", "32", "icon_32x32.png"], + ["64", "64", "icon_32x32@2x.png"], + ["128", "128", "icon_128x128.png"], + ["256", "256", "icon_128x128@2x.png"], + ["256", "256", "icon_256x256.png"], + ["512", "512", "icon_256x256@2x.png"], + ["512", "512", "icon_512x512.png"], + ]; + + for (const [heightPx, widthPx, filename] of iconSizes) { + await runCommandCapture("sips", [ + "-z", + heightPx, + widthPx, + icon1024, + "--out", + path.join(iconsetDir, filename), + ]); + } + + await runCommandCapture("iconutil", ["-c", "icns", iconsetDir, "-o", outputPath]); + } finally { + await fsp.rm(tmpDir, { recursive: true, force: true }); + } +} + +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 }); + await generateMacAppIcon(path.join(resourcesDir, "AppIcon.icns")); + + const infoPlistPath = path.join(contentsDir, "Info.plist"); + const target = macLauncherTarget(choice); + const infoPlist = ` + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + Pornboss + CFBundleExecutable + Pornboss + CFBundleIconFile + AppIcon + 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 +551,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..6954443 --- /dev/null +++ b/scripts/cli/macos-launcher.swift @@ -0,0 +1,396 @@ +import Cocoa +import Darwin +import Foundation + +private let startupURLPattern = try! NSRegularExpression( + pattern: #"https?://localhost:\d+"#, + options: [] +) + +private func systemPrefersChinese() -> Bool { + let candidates = ProcessInfo.processInfo.environment["AppleLanguages"] + .map { [$0] } ?? [] + let localeCandidates = Locale.preferredLanguages + for value in candidates + localeCandidates { + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { + continue + } + let tokens = normalized.split(whereSeparator: { + ":;, \n\r\t()[]{}\"'".contains($0) + }) + for token in tokens { + var part = String(token) + if let idx = part.firstIndex(where: { $0 == "." || $0 == "@" }) { + part = String(part[.. String { + prefersChinese ? chinese : english +} + +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: localized("退出 \(appName)", "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: localized("正在启动 Pornboss...", "Starting Pornboss...")) + statusLabel.font = .systemFont(ofSize: 14) + statusLabel.alignment = .center + statusLabel.translatesAutoresizingMaskIntoConstraints = false + + let openButton = NSButton( + title: localized("打开页面", "Open Page"), + target: self, + action: #selector(openPageSelected(_:)) + ) + openButton.bezelStyle = .rounded + openButton.isEnabled = false + openButton.translatesAutoresizingMaskIntoConstraints = false + + let quitButton = NSButton(title: localized("退出", "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..
@@ -1364,30 +1364,6 @@ export default function App() { {String(activeError)} )} - {showDirectorySetupHint && ( -
-
-
-
- {zh('还没有可用的视频目录', 'No video directories available yet')} -
-
- {zh( - '请点击右上角“全局设置”,然后在“目录管理”里添加目录。', - 'Open Global Settings in the top-right corner, then add a directory in Directory Management.' - )} -
-
- -
-
- )} {isJavMode ? ( javTab === 'idol' ? ( diff --git a/web/src/components/TopBar.jsx b/web/src/components/TopBar.jsx index 96c3cac..2602f64 100644 --- a/web/src/components/TopBar.jsx +++ b/web/src/components/TopBar.jsx @@ -5,6 +5,7 @@ import LocalOfferOutlinedIcon from '@mui/icons-material/LocalOfferOutlined' import ShuffleOutlinedIcon from '@mui/icons-material/ShuffleOutlined' import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined' import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined' +import ArrowForwardRoundedIcon from '@mui/icons-material/ArrowForwardRounded' import { zh } from '@/utils/i18n' export default function TopBar({ @@ -33,6 +34,7 @@ export default function TopBar({ javTab, onSwitchJavTab, filterSummary, + showDirectorySetupHint, }) { const headerRef = useRef(null) const headerClassName = ['sticky top-0 z-40 border-b bg-white/80 backdrop-blur'] @@ -231,7 +233,25 @@ export default function TopBar({ -
+
+ {showDirectorySetupHint ? ( +
+ + {zh( + '您还没有添加目录,点击此处在目录管理内添加', + 'No directories yet. Click here to add one in Directory Management' + )} + +
+ ) : null}