7 Commits

Author SHA1 Message Date
Micha
0f1c876520 Add DMG packaging to release script 2025-11-20 00:17:17 +01:00
Micha
2b3850440f Read version from build settings in build script 2025-11-20 00:10:31 +01:00
Micha
756695c5b0 Stop compiling NOTES.md 2025-11-20 00:04:45 +01:00
Micha
726df91d2d Replace CI build with local release script 2025-11-19 23:55:18 +01:00
Micha
f6c4773ac7 Require self-hosted mac runner for CI build
Some checks failed
Build macOS App / build (push) Has been cancelled
2025-11-19 23:45:17 +01:00
Micha
f1367287de Add Gitea workflow for macOS builds
Some checks failed
Build macOS App / build (push) Has been cancelled
2025-11-19 23:40:23 +01:00
Micha
6b8d458605 Improve startup UX with placeholders and prefetch 2025-11-19 23:28:12 +01:00
8 changed files with 205 additions and 34 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.DS_Store
xcuserdata/
DerivedData/
build/
Build/

View File

@@ -36,6 +36,16 @@ git clone https://git.24unix.net/tracer/iKeyMon
cd iKeyMon
open iKeyMon.xcodeproj
```
### Local release build
Use the helper script to produce a zipped `.app` in `dist/`:
```bash
./scripts/build_release.sh
```
It cleans previous artifacts, builds the `Release` configuration, and drops `iKeyMon-<version>.zip` into the `dist` folder (ignored by git).
## 📦 License
MIT — see [LICENSE](LICENSE) for details.

View File

@@ -0,0 +1,51 @@
import SwiftUI
struct ShimmerModifier: ViewModifier {
var active: Bool
@State private var phase: CGFloat = -1
func body(content: Content) -> some View {
content
.overlay(
shimmer
.mask(content)
.opacity(active ? 1 : 0)
)
.onAppear {
guard active else { return }
animate()
}
.onChange(of: active) { isActive in
if isActive {
phase = -1
animate()
}
}
}
private var shimmer: some View {
LinearGradient(
gradient: Gradient(colors: [
.clear,
Color.white.opacity(0.6),
.clear
]),
startPoint: .top,
endPoint: .bottom
)
.rotationEffect(.degrees(70))
.offset(x: phase * 250)
}
private func animate() {
withAnimation(.linear(duration: 1.2).repeatForever(autoreverses: false)) {
phase = 1
}
}
}
extension View {
func shimmering(active: Bool) -> some View {
modifier(ShimmerModifier(active: active))
}
}

View File

@@ -103,16 +103,26 @@ struct MainView: View {
}
}
.onAppear {
let initialID: UUID?
if let storedID = UserDefaults.standard.string(forKey: "selectedServerID"),
let uuid = UUID(uuidString: storedID),
servers.contains(where: { $0.id == uuid }) {
print("✅ [MainView] Restored selected server \(uuid)")
selectedServerID = uuid
} else if selectedServerID == nil, let first = servers.first {
initialID = uuid
} else if let first = servers.first {
print("✅ [MainView] Selecting first server \(first.hostname)")
selectedServerID = first.id
initialID = first.id
} else {
print(" [MainView] No stored selection")
initialID = nil
}
selectedServerID = initialID
if let initialID {
Task {
await prefetchOtherServers(activeID: initialID)
}
}
pingAllServers()
pingTimer = Timer.scheduledTimer(withTimeInterval: 10.0, repeats: true) { _ in
@@ -158,6 +168,39 @@ struct MainView: View {
}
}
private func prefetchOtherServers(activeID: UUID) async {
let others = servers.filter { $0.id != activeID }
await withTaskGroup(of: Void.self) { group in
for server in others {
group.addTask {
await fetchServerInfoAsync(for: server.id)
}
}
}
}
private func fetchServerInfoAsync(for id: UUID) async {
guard let server = servers.first(where: { $0.id == id }) else { return }
guard let apiKey = KeychainHelper.loadApiKey(for: server.hostname)?.trimmingCharacters(in: .whitespacesAndNewlines),
!apiKey.isEmpty,
let baseURL = URL(string: "https://\(server.hostname)")
else { return }
do {
let api = try await APIFactory.detectAndCreateAPI(baseURL: baseURL, apiKey: apiKey)
let info = try await api.fetchServerSummary(apiKey: apiKey)
await MainActor.run {
if let index = servers.firstIndex(where: { $0.id == id }) {
var updated = servers[index]
updated.info = info
servers[index] = updated
}
}
} catch {
print("❌ Prefetch failed for \(server.hostname): \(error)")
}
}
private func moveServer(from source: IndexSet, to destination: Int) {
servers.move(fromOffsets: source, toOffset: destination)
saveServerOrder()

View File

@@ -17,6 +17,7 @@ struct TableRowView<Label: View, Value: View>: View {
HStack(alignment: .top) {
label()
.frame(width: 180, alignment: .leading)
.unredacted()
value()
.frame(maxWidth: .infinity, alignment: .leading)

View File

@@ -12,6 +12,10 @@ struct ServerDetailView: View {
var isFetching: Bool
@AppStorage("showIntervalIndicator") private var showIntervalIndicator: Bool = true
private var showPlaceholder: Bool {
server.info == nil
}
@State private var progress: Double = 0
let timer = Timer.publish(every: 1.0 / 60.0, on: .main, in: .common).autoconnect()
@@ -24,37 +28,33 @@ struct ServerDetailView: View {
.frame(height: 2)
}
if server.info == nil {
ProgressView("Fetching server info...")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ZStack(alignment: .topTrailing) {
VStack(spacing: 0) {
Spacer().frame(height: 6)
TabView {
GeneralView(server: $server)
.tabItem {
Text("General")
}
ResourcesView(server: $server)
.tabItem {
Text("Resources")
}
ServicesView(server: $server)
.tabItem {
Text("Services")
}
}
}
if isFetching {
ProgressView()
.scaleEffect(0.5)
.padding()
ZStack(alignment: .topTrailing) {
VStack(spacing: 0) {
Spacer().frame(height: 6)
TabView {
GeneralView(server: resolvedBinding)
.tabItem {
Text("General").unredacted()
}
ResourcesView(server: resolvedBinding)
.tabItem {
Text("Resources").unredacted()
}
ServicesView(server: resolvedBinding)
.tabItem {
Text("Services").unredacted()
}
}
.redacted(reason: showPlaceholder ? .placeholder : [])
.shimmering(active: showPlaceholder)
}
if showPlaceholder || isFetching {
LoadingBadge()
.padding()
}
.padding(0)
}
.padding(0)
}
.onReceive(timer) { _ in
guard showIntervalIndicator else { return }
@@ -64,6 +64,17 @@ struct ServerDetailView: View {
}
}
}
private var resolvedBinding: Binding<Server> {
if showPlaceholder {
return .constant(placeholderServer())
}
return $server
}
private func placeholderServer() -> Server {
Server(id: server.id, hostname: server.hostname, info: .placeholder, pingable: server.pingable)
}
}
#Preview {
@@ -72,3 +83,16 @@ struct ServerDetailView: View {
isFetching: false
)
}
private struct LoadingBadge: View {
var body: some View {
HStack(spacing: 6) {
ProgressView()
.scaleEffect(0.5)
Text("Fetching latest data…")
.font(.caption)
}
.padding(8)
.background(.ultraThinMaterial, in: Capsule())
}
}

View File

@@ -9,7 +9,6 @@
/* Begin PBXBuildFile section */
52A9B79F2EC8E7EE004DD4A2 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B7872EC8E7EE004DD4A2 /* Assets.xcassets */; };
52A9B8222EC8FA8A004DD4A2 /* CHANGELOG.md in Resources */ = {isa = PBXBuildFile; fileRef = 52A9B8212EC8FA8A004DD4A2 /* CHANGELOG.md */; };
52A9B8BB2ECA3605004DD4A2 /* NOTES.md in Sources */ = {isa = PBXBuildFile; fileRef = 52A9B8BA2ECA35FB004DD4A2 /* NOTES.md */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -142,7 +141,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
52A9B8BB2ECA3605004DD4A2 /* NOTES.md in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

42
scripts/build_release.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
BUILD_DIR="$ROOT_DIR/build"
ARTIFACTS_DIR="$ROOT_DIR/dist"
SCHEME="iKeyMon"
PROJECT="iKeyMon.xcodeproj"
rm -rf "$BUILD_DIR" "$ARTIFACTS_DIR"
mkdir -p "$ARTIFACTS_DIR"
xcodebuild \
-project "$ROOT_DIR/$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-derivedDataPath "$BUILD_DIR" \
clean build
APP_PATH="$BUILD_DIR/Build/Products/Release/iKeyMon.app"
if [[ ! -d "$APP_PATH" ]]; then
echo "❌ Failed to find built app at $APP_PATH"
exit 1
fi
VERSION=$(xcodebuild \
-project "$ROOT_DIR/$PROJECT" \
-scheme "$SCHEME" \
-configuration Release \
-showBuildSettings | awk '/MARKETING_VERSION/ {print $3; exit}')
if [[ -z "$VERSION" ]]; then
VERSION="dev"
fi
ZIP_NAME="iKeyMon-${VERSION}.zip"
pushd "$(dirname "$APP_PATH")" >/dev/null
zip -r "$ARTIFACTS_DIR/$ZIP_NAME" "$(basename "$APP_PATH")"
popd >/dev/null
DMG_NAME="iKeyMon-${VERSION}.dmg"
hdiutil create -volname "iKeyMon" -srcfolder "$APP_PATH" -ov -format UDZO "$ARTIFACTS_DIR/$DMG_NAME"
echo "✅ Build complete. Artifact: $ARTIFACTS_DIR/$ZIP_NAME"