diff --git a/.github/workflows/native-macos-release.yml b/.github/workflows/native-macos-release.yml new file mode 100644 index 0000000..bcd3612 --- /dev/null +++ b/.github/workflows/native-macos-release.yml @@ -0,0 +1,80 @@ +name: Native macOS Release + +on: + workflow_dispatch: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + build: + runs-on: macos-15 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Import Developer ID certificate + env: + APPLE_CERTIFICATE_BASE64: ${{ secrets.APPLE_CERTIFICATE_BASE64 }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + set -euo pipefail + : "${APPLE_CERTIFICATE_BASE64:?Missing APPLE_CERTIFICATE_BASE64 secret}" + : "${APPLE_CERTIFICATE_PASSWORD:?Missing APPLE_CERTIFICATE_PASSWORD secret}" + : "${KEYCHAIN_PASSWORD:?Missing KEYCHAIN_PASSWORD secret}" + + CERT_PATH="$RUNNER_TEMP/developer-id.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/release-signing.keychain-db" + + echo "$APPLE_CERTIFICATE_BASE64" | base64 -d > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" \ + -P "$APPLE_CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security list-keychains -d user -s "$KEYCHAIN_PATH" + security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security find-identity -v -p codesigning "$KEYCHAIN_PATH" + + - name: Build, sign, and notarize DMG + env: + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + CODESIGN_IDENTITY: ${{ secrets.CODESIGN_IDENTITY }} + run: | + set -euo pipefail + : "${APPLE_APP_SPECIFIC_PASSWORD:?Missing APPLE_APP_SPECIFIC_PASSWORD secret}" + : "${APPLE_ID:?Missing APPLE_ID secret}" + : "${APPLE_TEAM_ID:?Missing APPLE_TEAM_ID secret}" + : "${CODESIGN_IDENTITY:?Missing CODESIGN_IDENTITY secret}" + + RELEASE=1 NOTARIZE=1 native-macos/scripts/build-app.sh + + - name: Upload workflow artifact + uses: actions/upload-artifact@v4 + with: + name: StandForge-Native-macOS + path: native-macos/.build/dmg/StandForge-Native-0.1.0-arm64.dmg + + - name: Upload release asset + if: startsWith(github.ref, 'refs/tags/') + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || \ + gh release create "$GITHUB_REF_NAME" \ + --title "StandForge $GITHUB_REF_NAME" \ + --notes "Native macOS release." + gh release upload "$GITHUB_REF_NAME" \ + native-macos/.build/dmg/StandForge-Native-0.1.0-arm64.dmg \ + --clobber diff --git a/native-macos/README.md b/native-macos/README.md index 280d932..2a5eb5e 100644 --- a/native-macos/README.md +++ b/native-macos/README.md @@ -12,18 +12,37 @@ It lives next to the Tauri app so the project can keep two frontends: swift run --package-path native-macos StandForgeMac ``` -## Build a `.app` +## Build a `.app` and `.dmg` ```bash native-macos/scripts/build-app.sh ``` -The app bundle is written to: +The script writes a signed app bundle and a disk image to: ```text native-macos/.build/app/StandForge Native.app +native-macos/.build/dmg/StandForge-Native-0.1.1-arm64.dmg ``` +By default the app is signed with an ad hoc signature so local builds can be +verified with `codesign --verify`. Ad hoc signatures are not accepted by +Gatekeeper for downloaded GitHub release assets. + +For a public GitHub release, build with a Developer ID Application certificate +and notarize the DMG: + +```bash +RELEASE=1 \ +CODESIGN_IDENTITY="Developer ID Application: Example Name (TEAMID)" \ +NOTARIZE=1 \ +NOTARY_KEYCHAIN_PROFILE="standforge-notary" \ +native-macos/scripts/build-app.sh +``` + +Alternatively, omit `NOTARY_KEYCHAIN_PROFILE` and provide `APPLE_ID`, +`APPLE_TEAM_ID`, and `APPLE_APP_SPECIFIC_PASSWORD`. + ## Liquid Glass behavior The SwiftUI version uses `GlassEffectContainer`, `glassEffect(_:in:)`, and the diff --git a/native-macos/Sources/StandForgeMac/StandForgeMac.swift b/native-macos/Sources/StandForgeMac/StandForgeMac.swift index 4a84762..2042048 100644 --- a/native-macos/Sources/StandForgeMac/StandForgeMac.swift +++ b/native-macos/Sources/StandForgeMac/StandForgeMac.swift @@ -3,8 +3,14 @@ import SwiftUI import UserNotifications private let compactWindowSize = NSSize(width: 340, height: 80) +private let minimumCompactWindowSize = NSSize(width: 260, height: 40) private let expandedWindowSize = NSSize(width: 340, height: 520) +private func formatTime(_ seconds: Int) -> String { + let safeSeconds = max(0, seconds) + return String(format: "%02d:%02d", safeSeconds / 60, safeSeconds % 60) +} + @main enum StandForgeMacLauncher { static func main() { @@ -22,12 +28,26 @@ enum StandForgeMacLauncher { private final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { private let timerModel = StandForgeTimerModel() private var window: NSWindow? + private var statusItem: NSStatusItem? + private var statusMenu: NSMenu? + private var showWindowMenuItem: NSMenuItem? + private var hideWindowMenuItem: NSMenuItem? + private var notificationsMenuItem: NSMenuItem? + private var soundMenuItem: NSMenuItem? + private var statusRefreshTimer: Timer? func applicationDidFinishLaunching(_ notification: Notification) { UNUserNotificationCenter.current().delegate = self timerModel.requestNotificationPermission() + createStatusItem() createFloatingWindow() timerModel.startIfNeeded() + updateStatusItem() + statusRefreshTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.updateStatusItem() + } + } } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -43,14 +63,23 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotifica } private func createFloatingWindow() { - let rootView = FloatingTimerWindow(model: timerModel) { [weak self] expanded in - self?.resizeFloatingWindow(expanded: expanded) - } + let rootView = FloatingTimerWindow( + model: timerModel, + onExpansionChange: { [weak self] expanded in + self?.resizeFloatingWindow(expanded: expanded) + }, + onHide: { [weak self] in + self?.hideFloatingWindow() + }, + onQuit: { + NSApplication.shared.terminate(nil) + } + ) let hostingController = NSHostingController(rootView: rootView) let window = NSWindow( contentRect: NSRect(origin: .zero, size: compactWindowSize), - styleMask: [.borderless], + styleMask: [.borderless, .resizable], backing: .buffered, defer: false ) @@ -63,6 +92,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotifica window.level = .floating window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] window.isMovableByWindowBackground = true + window.minSize = minimumCompactWindowSize if let screen = NSScreen.main { let frame = screen.visibleFrame @@ -73,6 +103,127 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotifica self.window = window } + private func createStatusItem() { + let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) + statusItem.button?.image = StandForgeStatusIcon.makeImage() + statusItem.button?.imagePosition = .imageLeading + statusItem.button?.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold) + + let menu = NSMenu() + let showItem = statusMenuItem(title: "显示悬浮窗", action: #selector(showFloatingWindow)) + let hideItem = statusMenuItem(title: "隐藏悬浮窗", action: #selector(hideFloatingWindow)) + menu.addItem(showItem) + menu.addItem(hideItem) + menu.addItem(.separator()) + let notificationsItem = statusMenuItem(title: "系统通知", action: #selector(toggleNotificationsFromMenu)) + let soundItem = statusMenuItem(title: "提醒声音", action: #selector(toggleSoundFromMenu)) + menu.addItem(notificationsItem) + menu.addItem(soundItem) + menu.addItem(.separator()) + menu.addItem(statusMenuItem(title: "暂停", action: #selector(togglePauseFromMenu))) + menu.addItem(statusMenuItem(title: "结束本轮", action: #selector(stopFromMenu))) + menu.addItem(.separator()) + menu.addItem(statusMenuItem(title: "退出 StandForge", action: #selector(quitFromMenu), keyEquivalent: "q")) + + statusItem.menu = menu + self.statusItem = statusItem + self.statusMenu = menu + self.showWindowMenuItem = showItem + self.hideWindowMenuItem = hideItem + self.notificationsMenuItem = notificationsItem + self.soundMenuItem = soundItem + } + + private func statusMenuItem(title: String, action: Selector, keyEquivalent: String = "") -> NSMenuItem { + let item = NSMenuItem(title: title, action: action, keyEquivalent: keyEquivalent) + item.target = self + return item + } + + private func updateStatusItem() { + statusItem?.button?.title = " \(formatTime(timerModel.displaySeconds))" + statusItem?.button?.toolTip = "\(timerModel.phaseLabel) · \(formatTime(timerModel.displaySeconds))" + + guard let statusMenu else { return } + let isWindowVisible = window?.isVisible == true + showWindowMenuItem?.state = isWindowVisible ? .on : .off + hideWindowMenuItem?.state = isWindowVisible ? .off : .on + statusMenu.item(at: 0)?.isEnabled = true + statusMenu.item(at: 1)?.isEnabled = isWindowVisible + notificationsMenuItem?.state = timerModel.notificationsEnabled ? .on : .off + soundMenuItem?.state = timerModel.soundEnabled ? .on : .off + statusMenu.item(at: 6)?.title = timerModel.phase == .paused ? "继续" : "暂停" + } + + @objc private func showFloatingWindow() { + guard let window else { return } + keepWindowVisible(window) + NSApplication.shared.activate(ignoringOtherApps: true) + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + updateStatusItem() + } + + @objc private func hideFloatingWindow() { + window?.orderOut(nil) + updateStatusItem() + } + + @objc private func togglePauseFromMenu() { + timerModel.togglePause() + updateStatusItem() + } + + @objc private func toggleNotificationsFromMenu() { + timerModel.notificationsEnabled.toggle() + if timerModel.notificationsEnabled { + timerModel.requestNotificationPermission() + } + updateStatusItem() + } + + @objc private func toggleSoundFromMenu() { + timerModel.soundEnabled.toggle() + updateStatusItem() + } + + @objc private func stopFromMenu() { + timerModel.stop() + updateStatusItem() + } + + @objc private func quitFromMenu() { + NSApplication.shared.terminate(nil) + } + + private func keepWindowVisible(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { return } + let visibleFrame = screen.visibleFrame + var frame = window.frame + + if frame.width < minimumCompactWindowSize.width { + frame.size.width = minimumCompactWindowSize.width + } + if frame.height < minimumCompactWindowSize.height { + frame.size.height = minimumCompactWindowSize.height + } + + if frame.maxX > visibleFrame.maxX { + frame.origin.x = visibleFrame.maxX - frame.width - 12 + } + if frame.minX < visibleFrame.minX { + frame.origin.x = visibleFrame.minX + 12 + } + if frame.maxY > visibleFrame.maxY { + frame.origin.y = visibleFrame.maxY - frame.height - 12 + } + if frame.minY < visibleFrame.minY { + frame.origin.y = visibleFrame.minY + 12 + } + + window.setFrame(frame, display: true) + } + private func resizeFloatingWindow(expanded: Bool) { guard let window else { return } let targetSize = expanded ? expandedWindowSize : compactWindowSize @@ -88,7 +239,28 @@ private final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotifica } } -private enum TimerPhase { +private enum StandForgeStatusIcon { + static func makeImage() -> NSImage { + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size) + image.lockFocus() + NSColor.labelColor.setStroke() + NSBezierPath(roundedRect: NSRect(x: 3, y: 3, width: 12, height: 12), xRadius: 3, yRadius: 3).stroke() + let standPath = NSBezierPath() + standPath.lineWidth = 2 + standPath.lineCapStyle = .round + standPath.move(to: NSPoint(x: 7, y: 5)) + standPath.line(to: NSPoint(x: 7, y: 13)) + standPath.move(to: NSPoint(x: 11, y: 5)) + standPath.line(to: NSPoint(x: 11, y: 13)) + standPath.stroke() + image.unlockFocus() + image.isTemplate = true + return image + } +} + +private enum TimerPhase: Equatable { case idle case sitting case standPending @@ -192,6 +364,14 @@ private final class StandForgeTimerModel: ObservableObject { } } + func togglePause() { + if phase == .paused { + resume() + } else { + pause() + } + } + func startIfNeeded() { guard phase == .idle else { return } startSitting() @@ -320,72 +500,131 @@ private enum FloatingTab: String, CaseIterable, Identifiable { private struct FloatingTimerWindow: View { @ObservedObject var model: StandForgeTimerModel let onExpansionChange: (Bool) -> Void + let onHide: () -> Void + let onQuit: () -> Void @State private var isExpanded = false @State private var selectedTab: FloatingTab = .reminder var body: some View { - StandForgeGlassContainer { - VStack(spacing: isExpanded ? 12 : 0) { - header - .frame(height: 58) - - if isExpanded { - expandedPanel + GeometryReader { proxy in + let compactProgress = max(0, min(1, (proxy.size.height - minimumCompactWindowSize.height) / 40)) + let headerHeight = isExpanded ? 58 : max(34, proxy.size.height - 22) + let timeSize = isExpanded ? 34 : 22 + (12 * compactProgress) + let titleOpacity = isExpanded ? 1 : compactProgress + let horizontalPadding = isExpanded ? 10 : 8 + (3 * compactProgress) + + StandForgeGlassContainer { + VStack(spacing: isExpanded ? 12 : 0) { + header( + availableWidth: proxy.size.width - (horizontalPadding * 2), + timeSize: timeSize, + titleOpacity: titleOpacity, + compactProgress: compactProgress + ) + .frame(height: headerHeight) + + if isExpanded { + expandedPanel + } + } + .padding(.vertical, isExpanded ? 10 : 3 + (8 * compactProgress)) + .padding(.horizontal, horizontalPadding) + .frame( + minWidth: minimumCompactWindowSize.width, + maxWidth: .infinity, + minHeight: isExpanded ? 420 : minimumCompactWindowSize.height, + maxHeight: .infinity + ) + .standForgeGlass( + RoundedRectangle(cornerRadius: isExpanded ? 20 : 16 + (2 * compactProgress), style: .continuous), + interactive: false + ) + .animation(.smooth(duration: 0.24), value: isExpanded) + .onChange(of: isExpanded) { _, value in + onExpansionChange(value) } - } - .padding(isExpanded ? 10 : 11) - .frame(width: 340, height: isExpanded ? 520 : 80) - .standForgeGlass( - RoundedRectangle(cornerRadius: isExpanded ? 20 : 18, style: .continuous), - interactive: false - ) - .animation(.smooth(duration: 0.24), value: isExpanded) - .onChange(of: isExpanded) { _, value in - onExpansionChange(value) } } } - private var header: some View { - HStack(alignment: .center, spacing: 12) { + private func header( + availableWidth: Double, + timeSize: Double, + titleOpacity: Double, + compactProgress: Double + ) -> some View { + let widthProgress = max(0, min(1, (availableWidth - 244) / 92)) + let buttonSize = 24 + (4 * min(compactProgress, widthProgress)) + let iconSize = 11.5 + (1.5 * min(compactProgress, widthProgress)) + let controlSpacing = 4 + (4 * widthProgress) + let showSecondaryControls = widthProgress > 0.2 + let labelText = availableWidth < 284 ? model.phaseLabel.replacingOccurrences(of: "使用", with: "") : model.phaseLabel + let statusWidth = max(44, min(78, availableWidth * 0.24)) + + return HStack(alignment: .center, spacing: 6 + (6 * widthProgress)) { VStack(alignment: .leading, spacing: 2) { Text("StandForge") - .font(.system(size: 13, weight: .medium)) + .font(.system(size: 11 + (2 * titleOpacity), weight: .medium)) .foregroundStyle(.secondary) + .opacity(titleOpacity * widthProgress) + .frame(height: titleOpacity * widthProgress > 0.18 ? nil : 0) Text(formatTime(model.displaySeconds)) - .font(.system(size: 34, weight: .bold, design: .rounded).monospacedDigit()) + .font(.system(size: timeSize, weight: .bold, design: .rounded).monospacedDigit()) .foregroundStyle(.primary) .lineLimit(1) - .minimumScaleFactor(0.82) + .minimumScaleFactor(0.68) } - - Spacer(minLength: 8) - - VStack(alignment: .trailing, spacing: 6) { - Text(model.phaseLabel) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - .lineLimit(1) - - HStack(spacing: 8) { + .frame(minWidth: 82, alignment: .leading) + .layoutPriority(2) + + Spacer(minLength: 0) + + Text(labelText) + .font(.system(size: 10.5 + (2 * min(compactProgress, widthProgress)), weight: .semibold)) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.72) + .frame(width: statusWidth, alignment: .trailing) + .offset(x: showSecondaryControls ? 0 : -4) + .layoutPriority(1) + + HStack(spacing: controlSpacing) { + if showSecondaryControls { glassIconButton( - systemName: isExpanded ? "chevron.down" : "slider.horizontal.3", - accessibilityLabel: isExpanded ? "收起设置" : "展开设置" + systemName: "eye.slash", + accessibilityLabel: "隐藏悬浮窗", + size: buttonSize, + iconSize: iconSize ) { - isExpanded.toggle() + onHide() } + .transition(.opacity.combined(with: .scale(scale: 0.92))) glassIconButton( - systemName: model.primaryActionIcon, - accessibilityLabel: model.primaryActionTitle, - prominent: true + systemName: isExpanded ? "chevron.down" : "slider.horizontal.3", + accessibilityLabel: isExpanded ? "收起设置" : "展开设置", + size: buttonSize, + iconSize: iconSize ) { - model.primaryAction() + isExpanded.toggle() } + .transition(.opacity.combined(with: .scale(scale: 0.92))) + } + + glassIconButton( + systemName: model.primaryActionIcon, + accessibilityLabel: model.primaryActionTitle, + prominent: true, + size: buttonSize, + iconSize: iconSize + ) { + model.primaryAction() } } + .layoutPriority(3) + .animation(.smooth(duration: 0.18), value: showSecondaryControls) } } @@ -499,6 +738,15 @@ private struct FloatingTimerWindow: View { sliderPanel(title: "屏幕使用", value: $model.sitMinutes, range: 5...90, step: 5) sliderPanel(title: "站立", value: $model.standMinutes, range: 3...30, step: 1) + + HStack(spacing: 8) { + glassTextButton(title: "隐藏悬浮窗", systemName: "eye.slash") { + onHide() + } + glassTextButton(title: "退出", systemName: "power") { + onQuit() + } + } } .padding(.bottom, 4) } @@ -573,13 +821,15 @@ private struct FloatingTimerWindow: View { systemName: String, accessibilityLabel: String, prominent: Bool = false, + size: Double = 28, + iconSize: Double = 13, action: @escaping () -> Void ) -> some View { Button(action: action) { Image(systemName: systemName) - .font(.system(size: 13, weight: .semibold)) + .font(.system(size: iconSize, weight: .semibold)) .foregroundStyle(prominent ? .white : .primary) - .frame(width: 28, height: 28) + .frame(width: size, height: size) .contentShape(Circle()) .standForgeGlass(Circle(), interactive: true, tint: prominent ? .teal : nil) } @@ -611,11 +861,6 @@ private struct FloatingTimerWindow: View { } .buttonStyle(.plain) } - - private func formatTime(_ seconds: Int) -> String { - let safeSeconds = max(0, seconds) - return String(format: "%02d:%02d", safeSeconds / 60, safeSeconds % 60) - } } private struct StandForgeGlassContainer: View { diff --git a/native-macos/scripts/build-app.sh b/native-macos/scripts/build-app.sh index 7346faa..2cb010c 100755 --- a/native-macos/scripts/build-app.sh +++ b/native-macos/scripts/build-app.sh @@ -3,19 +3,38 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_DIR="$(cd "$PROJECT_DIR/.." && pwd)" BUILD_DIR="$PROJECT_DIR/.build" +APP_NAME="StandForge Native" +BUNDLE_ID="com.standforge.native" +VERSION="0.1.1" APP_DIR="$BUILD_DIR/app/StandForge Native.app" EXECUTABLE="$BUILD_DIR/release/StandForgeMac" +DMG_DIR="$BUILD_DIR/dmg" +DMG_STAGING_DIR="$BUILD_DIR/dmg-staging" +DMG_PATH="$DMG_DIR/StandForge-Native-$VERSION-arm64.dmg" +SIGN_IDENTITY="${CODESIGN_IDENTITY:--}" +RELEASE="${RELEASE:-0}" +NOTARIZE="${NOTARIZE:-0}" + +if [[ "$RELEASE" == "1" && "$SIGN_IDENTITY" == "-" ]]; then + echo "RELEASE=1 requires CODESIGN_IDENTITY to be a Developer ID Application certificate." >&2 + exit 1 +fi swift build --package-path "$PROJECT_DIR" -c release rm -rf "$APP_DIR" -mkdir -p "$APP_DIR/Contents/MacOS" +mkdir -p "$APP_DIR/Contents/MacOS" "$APP_DIR/Contents/Resources" cp "$EXECUTABLE" "$APP_DIR/Contents/MacOS/StandForgeMac" chmod +x "$APP_DIR/Contents/MacOS/StandForgeMac" -cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' +if [[ -f "$REPO_DIR/src-tauri/icons/icon.icns" ]]; then + cp "$REPO_DIR/src-tauri/icons/icon.icns" "$APP_DIR/Contents/Resources/icon.icns" +fi + +cat > "$APP_DIR/Contents/Info.plist" < @@ -23,17 +42,19 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' CFBundleExecutable StandForgeMac CFBundleIdentifier - com.standforge.native + $BUNDLE_ID + CFBundleIconFile + icon.icns CFBundleName - StandForge Native + $APP_NAME CFBundleDisplayName - StandForge Native + $APP_NAME CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + $VERSION CFBundleVersion - 1 + $VERSION LSMinimumSystemVersion 15.0 LSUIElement @@ -42,4 +63,42 @@ cat > "$APP_DIR/Contents/Info.plist" <<'PLIST' PLIST +if [[ "$SIGN_IDENTITY" == "-" ]]; then + codesign --force --deep --options runtime --timestamp=none --sign "$SIGN_IDENTITY" "$APP_DIR" +else + codesign --force --deep --options runtime --timestamp --sign "$SIGN_IDENTITY" "$APP_DIR" +fi + +codesign --verify --deep --strict --verbose=4 "$APP_DIR" + +rm -rf "$DMG_STAGING_DIR" "$DMG_PATH" +mkdir -p "$DMG_STAGING_DIR" "$DMG_DIR" +cp -R "$APP_DIR" "$DMG_STAGING_DIR/$APP_NAME.app" +ln -s /Applications "$DMG_STAGING_DIR/Applications" +hdiutil create -volname "$APP_NAME" -srcfolder "$DMG_STAGING_DIR" -ov -format UDZO "$DMG_PATH" +hdiutil verify "$DMG_PATH" + +if [[ "$RELEASE" == "1" ]]; then + codesign --force --timestamp --sign "$SIGN_IDENTITY" "$DMG_PATH" + codesign --verify --verbose=4 "$DMG_PATH" +fi + +if [[ "$NOTARIZE" == "1" ]]; then + if [[ -n "${NOTARY_KEYCHAIN_PROFILE:-}" ]]; then + xcrun notarytool submit "$DMG_PATH" --keychain-profile "$NOTARY_KEYCHAIN_PROFILE" --wait + else + : "${APPLE_ID:?APPLE_ID is required when NOTARIZE=1 without NOTARY_KEYCHAIN_PROFILE}" + : "${APPLE_TEAM_ID:?APPLE_TEAM_ID is required when NOTARIZE=1 without NOTARY_KEYCHAIN_PROFILE}" + : "${APPLE_APP_SPECIFIC_PASSWORD:?APPLE_APP_SPECIFIC_PASSWORD is required when NOTARIZE=1 without NOTARY_KEYCHAIN_PROFILE}" + xcrun notarytool submit "$DMG_PATH" \ + --apple-id "$APPLE_ID" \ + --team-id "$APPLE_TEAM_ID" \ + --password "$APPLE_APP_SPECIFIC_PASSWORD" \ + --wait + fi + xcrun stapler staple "$DMG_PATH" + spctl -a -vvv -t open "$DMG_PATH" +fi + echo "$APP_DIR" +echo "$DMG_PATH" diff --git a/package-lock.json b/package-lock.json index 4a0b80b..4d092ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "standforge", - "version": "0.1.0", + "version": "0.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "standforge", - "version": "0.1.0", + "version": "0.1.1", "dependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slider": "^1.3.6", diff --git a/package.json b/package.json index a462b3c..8e046ac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "standforge", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7b8c479..75b7542 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -328,6 +328,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.0" @@ -1558,7 +1564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98" dependencies = [ "byteorder", - "png", + "png 0.17.16", ] [[package]] @@ -1669,6 +1675,19 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -2015,6 +2034,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.17.1" @@ -2030,7 +2059,7 @@ dependencies = [ "objc2-core-foundation", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", @@ -2616,6 +2645,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.10.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -2728,6 +2770,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3376,6 +3424,20 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "standforge" +version = "0.1.0" +dependencies = [ + "chrono", + "rusqlite", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "uuid", +] + [[package]] name = "string_cache" version = "0.8.9" @@ -3547,6 +3609,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", + "image", "jni", "libc", "log", @@ -3581,20 +3644,6 @@ dependencies = [ "windows", ] -[[package]] -name = "standforge" -version = "0.1.0" -dependencies = [ - "chrono", - "rusqlite", - "serde", - "serde_json", - "tauri", - "tauri-build", - "tauri-plugin-opener", - "uuid", -] - [[package]] name = "tauri-build" version = "2.5.3" @@ -3628,7 +3677,7 @@ dependencies = [ "ico", "json-patch", "plist", - "png", + "png 0.17.16", "proc-macro2", "quote", "semver", @@ -4118,7 +4167,7 @@ dependencies = [ "objc2-core-graphics", "objc2-foundation", "once_cell", - "png", + "png 0.17.16", "serde", "thiserror 2.0.18", "windows-sys 0.60.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fa21dfa..246fc41 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] tauri-build = { version = "2", features = [] } [dependencies] -tauri = { version = "2", features = ["macos-private-api"] } +tauri = { version = "2", features = ["macos-private-api", "tray-icon", "image-png"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..5fae119 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..1b32776 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..a341835 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..40b2735 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..464e94d 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/icons/standforge-logo.svg b/src-tauri/icons/standforge-logo.svg new file mode 100644 index 0000000..a3cab3b --- /dev/null +++ b/src-tauri/icons/standforge-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src-tauri/icons/standforge-tray.png b/src-tauri/icons/standforge-tray.png new file mode 100644 index 0000000..1d29f84 Binary files /dev/null and b/src-tauri/icons/standforge-tray.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index da03254..cd87dd1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod db; mod models; mod timer; mod commands; +mod tray; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -30,12 +31,14 @@ pub fn run() { app.manage(commands::AppState { timer: timer.clone(), }); + tray::setup(app, timer.clone())?; let app_handle = app.handle().clone(); thread::spawn(move || { loop { if let Ok(timer) = timer.lock() { timer.update(&app_handle); + tray::update(&app_handle, &timer); } thread::sleep(Duration::from_secs(1)); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs new file mode 100644 index 0000000..632ad8d --- /dev/null +++ b/src-tauri/src/tray.rs @@ -0,0 +1,118 @@ +use crate::timer::{StandTimer, TimerState}; +use std::sync::{Arc, Mutex}; +use tauri::{ + image::Image, + menu::{MenuBuilder, MenuItemBuilder}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + Manager, +}; + +const TRAY_ID: &str = "standforge-main"; +const MENU_SHOW: &str = "standforge-show"; +const MENU_HIDE: &str = "standforge-hide"; +const MENU_TOGGLE_PAUSE: &str = "standforge-toggle-pause"; +const MENU_STOP: &str = "standforge-stop"; +const MENU_QUIT: &str = "standforge-quit"; + +pub fn setup(app: &tauri::App, timer: Arc>) -> tauri::Result<()> { + let menu = MenuBuilder::new(app) + .item(&MenuItemBuilder::with_id(MENU_SHOW, "显示悬浮窗").build(app)?) + .item(&MenuItemBuilder::with_id(MENU_HIDE, "隐藏悬浮窗").build(app)?) + .separator() + .item(&MenuItemBuilder::with_id(MENU_TOGGLE_PAUSE, "暂停 / 继续").build(app)?) + .item(&MenuItemBuilder::with_id(MENU_STOP, "结束本轮").build(app)?) + .separator() + .item(&MenuItemBuilder::with_id(MENU_QUIT, "退出 StandForge").build(app)?) + .build()?; + + let icon = Image::from_bytes(include_bytes!("../icons/standforge-tray.png"))?; + let tray_timer = timer.clone(); + + TrayIconBuilder::with_id(TRAY_ID) + .icon(icon) + .icon_as_template(true) + .title("45:00") + .tooltip("StandForge") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| match event.id().as_ref() { + MENU_SHOW => show_window(app), + MENU_HIDE => hide_window(app), + MENU_TOGGLE_PAUSE => toggle_pause(&tray_timer), + MENU_STOP => stop_timer(&tray_timer), + MENU_QUIT => app.exit(0), + _ => {} + }) + .on_tray_icon_event(|tray, event| { + if let TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } = event + { + show_window(tray.app_handle()); + } + }) + .build(app)?; + + Ok(()) +} + +pub fn update(app: &tauri::AppHandle, timer: &StandTimer) { + let Some(tray) = app.tray_by_id(TRAY_ID) else { + return; + }; + + let remaining = timer.get_remaining_seconds(); + let title = format_time(remaining); + let label = phase_label(timer.get_state()); + let tooltip = format!("StandForge · {label} · {title}"); + + let _ = tray.set_title(Some(title)); + let _ = tray.set_tooltip(Some(tooltip)); +} + +fn show_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("floating") { + let _ = window.show(); + let _ = window.set_focus(); + } +} + +fn hide_window(app: &tauri::AppHandle) { + if let Some(window) = app.get_webview_window("floating") { + let _ = window.hide(); + } +} + +fn toggle_pause(timer: &Arc>) { + if let Ok(timer) = timer.lock() { + if timer.get_state() == TimerState::Paused { + timer.resume(); + } else { + timer.pause(); + } + } +} + +fn stop_timer(timer: &Arc>) { + if let Ok(timer) = timer.lock() { + timer.stop(); + } +} + +fn phase_label(state: TimerState) -> &'static str { + match state { + TimerState::Idle => "后台提醒", + TimerState::Sitting => "屏幕使用", + TimerState::StandPending => "该站一会儿", + TimerState::Standing => "站立中", + TimerState::Snoozed => "已延后", + TimerState::Paused => "已暂停", + } +} + +fn format_time(seconds: i64) -> String { + let safe_seconds = seconds.max(0); + format!("{:02}:{:02}", safe_seconds / 60, safe_seconds % 60) +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8ae973e..06cbaf1 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "StandForge", - "version": "0.1.0", + "version": "0.1.1", "identifier": "com.standforge.desktop", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/FloatingWindow.tsx b/src/components/FloatingWindow.tsx index 8d6c360..3981cb8 100644 --- a/src/components/FloatingWindow.tsx +++ b/src/components/FloatingWindow.tsx @@ -8,9 +8,11 @@ import { Check, ChevronDown, Clock3, + EyeOff, Pause, Palette, Play, + Power, RotateCcw, Settings2, SlidersHorizontal, @@ -234,6 +236,22 @@ export function FloatingWindow() { await pauseTimer(); }; + const handleHideWindow = async () => { + try { + await getCurrentWindow().hide(); + } catch { + // Browser preview has no native window to hide. + } + }; + + const handleQuitApp = async () => { + try { + await invoke('quit_app'); + } catch { + // Browser preview has no native app process to quit. + } + }; + const primaryLabel = isIdle ? '启动' : isStandPrompt @@ -265,6 +283,14 @@ export function FloatingWindow() {

{label}

+ + +
diff --git a/src/index.css b/src/index.css index 0267c22..ef7ad3a 100644 --- a/src/index.css +++ b/src/index.css @@ -477,12 +477,26 @@ html.is-floating { background: rgba(255, 255, 255, 0.86); } +.floating-danger-button { + border-color: hsl(var(--destructive) / 0.22); + color: hsl(var(--destructive)); +} + +.floating-danger-button:hover { + background: hsl(var(--destructive) / 0.08); +} + .dark .floating-chip-button { border-color: rgba(148, 163, 184, 0.18); background: rgba(255, 255, 255, 0.08); color: hsl(var(--foreground) / 0.86); } +.dark .floating-danger-button { + border-color: hsl(var(--destructive) / 0.36); + color: hsl(var(--destructive-foreground)); +} + .dark .floating-chip-button:hover { background: rgba(255, 255, 255, 0.13); }