release: prepare v0.4.15

This commit is contained in:
h3p 2026-02-14 23:15:22 +01:00
parent 43180529de
commit 763eea0e5e
8 changed files with 149 additions and 41 deletions

View file

@ -4,6 +4,11 @@ All notable changes to **Neon Vision Editor** are documented in this file.
The format follows *Keep a Changelog*. Versions use semantic versioning with prerelease tags.
## [v0.4.15] - 2026-02-14
### Fixed
- Fixed the editor `Highlight Current Line` behavior on macOS so previous line background highlights are cleared and only the active line remains highlighted.
## [v0.4.14] - 2026-02-14
### Added

View file

@ -358,7 +358,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 228;
CURRENT_PROJECT_VERSION = 229;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -401,7 +401,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.4.14;
MARKETING_VERSION = 0.4.15;
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -438,7 +438,7 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 228;
CURRENT_PROJECT_VERSION = 229;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = CS727NF72U;
ENABLE_APP_SANDBOX = YES;
@ -481,7 +481,7 @@
LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks";
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 0.4.14;
MARKETING_VERSION = 0.4.15;
PRODUCT_BUNDLE_IDENTIFIER = "h3p.Neon-Vision-Editor";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View file

@ -109,6 +109,9 @@ final class AppUpdateManager: ObservableObject {
@Published private(set) var automaticPromptToken: Int = 0
@Published private(set) var isInstalling: Bool = false
@Published private(set) var installMessage: String?
@Published private(set) var installProgress: Double = 0
@Published private(set) var installPhase: String = ""
@Published private(set) var awaitingInstallCompletionAction: Bool = false
@Published private(set) var lastCheckResultSummary: String = "Never checked"
private let owner: String
@ -250,7 +253,7 @@ final class AppUpdateManager: ObservableObject {
automaticPromptToken &+= 1
installMessage = "Automatic install is disabled while running from Xcode/DerivedData."
} else {
await attemptAutoInstall()
await attemptAutoInstall(interactive: false)
}
} else {
pendingAutomaticPrompt = true
@ -321,6 +324,9 @@ final class AppUpdateManager: ObservableObject {
func clearInstallMessage() {
installMessage = nil
installProgress = 0
installPhase = ""
awaitingInstallCompletionAction = false
}
func installUpdateNow() async {
@ -332,7 +338,24 @@ final class AppUpdateManager: ObservableObject {
installMessage = "Install now is disabled while running from Xcode/DerivedData. Use Download Update instead."
return
}
await attemptAutoInstall()
await attemptAutoInstall(interactive: true)
}
func completeInstalledUpdate(restart: Bool) {
#if os(macOS)
guard awaitingInstallCompletionAction else { return }
let currentApp = Bundle.main.bundleURL.standardizedFileURL
if restart {
installMessage = "Restarting app…"
NSWorkspace.shared.openApplication(at: currentApp, configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
} else {
installMessage = "Closing app to finish update…"
}
awaitingInstallCompletionAction = false
NSApp.terminate(nil)
#else
installMessage = "Automatic install is supported on macOS only."
#endif
}
private func shouldRunInitialCheckNow() -> Bool {
@ -486,7 +509,7 @@ final class AppUpdateManager: ObservableObject {
}
}
private func attemptAutoInstall() async {
private func attemptAutoInstall(interactive: Bool) async {
#if os(macOS)
guard !isInstalling else { return }
guard let release = latestRelease else { return }
@ -500,6 +523,9 @@ final class AppUpdateManager: ObservableObject {
}
isInstalling = true
installProgress = 0.01
installPhase = "Preparing installer…"
awaitingInstallCompletionAction = false
defer { isInstalling = false }
do {
@ -507,11 +533,15 @@ final class AppUpdateManager: ObservableObject {
// 1) verify artifact checksum from release metadata
// 2) verify code signature validity + signing identity
let expectedHash = try Self.extractSHA256(from: release.notes, preferredAssetName: assetName)
installProgress = 0.12
installPhase = "Downloading release asset…"
let (tmpURL, response) = try await session.download(from: downloadURL)
guard let http = response as? HTTPURLResponse, 200..<300 ~= http.statusCode else {
throw URLError(.badServerResponse)
}
installProgress = 0.40
installPhase = "Verifying checksum…"
let actualHash = try Self.sha256Hex(of: tmpURL)
guard actualHash.caseInsensitiveCompare(expectedHash) == .orderedSame else {
throw UpdateError.checksumMismatch
@ -525,6 +555,8 @@ final class AppUpdateManager: ObservableObject {
let downloadedZip = workDir.appendingPathComponent(assetName)
try fileManager.moveItem(at: tmpURL, to: downloadedZip)
installProgress = 0.56
installPhase = "Unpacking update…"
let unzipStatus = try Self.unzip(zipURL: downloadedZip, to: unzipDir)
guard unzipStatus == 0 else {
throw UpdateError.installUnsupported("Failed to unpack update archive.")
@ -533,6 +565,8 @@ final class AppUpdateManager: ObservableObject {
guard let appBundle = Self.findFirstAppBundle(in: unzipDir) else {
throw UpdateError.installUnsupported("No .app bundle found in downloaded update.")
}
installProgress = 0.70
installPhase = "Verifying app signature…"
guard try Self.verifyCodeSignatureStrictCLI(of: appBundle) else {
throw UpdateError.invalidCodeSignature
}
@ -556,6 +590,8 @@ final class AppUpdateManager: ObservableObject {
let backupApp = targetDir.appendingPathComponent("\(currentApp.deletingPathExtension().lastPathComponent)-backup-\(Int(Date().timeIntervalSince1970)).app")
do {
installProgress = 0.86
installPhase = "Installing app update…"
try fileManager.moveItem(at: currentApp, to: backupApp)
try fileManager.moveItem(at: appBundle, to: currentApp)
} catch {
@ -565,10 +601,19 @@ final class AppUpdateManager: ObservableObject {
throw UpdateError.installUnsupported("Automatic install failed due to file permissions. Use Download Update for manual install.")
}
installMessage = "Update installed. Relaunching…"
NSWorkspace.shared.openApplication(at: currentApp, configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
NSApp.terminate(nil)
installProgress = 1.0
installPhase = "Install complete."
if interactive {
awaitingInstallCompletionAction = true
installMessage = "Update installed. Choose “Restart App” or “Install & Close App”."
} else {
installMessage = "Update installed. Relaunching…"
NSWorkspace.shared.openApplication(at: currentApp, configuration: NSWorkspace.OpenConfiguration(), completionHandler: nil)
NSApp.terminate(nil)
}
} catch {
installProgress = 0
installPhase = ""
installMessage = error.localizedDescription
if let release = latestRelease {
openURL(release.downloadURL ?? release.releaseURL)

View file

@ -119,8 +119,15 @@ struct AppUpdaterDialog: View {
.foregroundStyle(.secondary)
if appUpdateManager.isInstalling {
ProgressView("Installing update…")
.font(.caption)
VStack(alignment: .leading, spacing: 6) {
ProgressView(value: appUpdateManager.installProgress, total: 1.0) {
Text(appUpdateManager.installPhase.isEmpty ? "Installing update…" : appUpdateManager.installPhase)
.font(.caption)
}
Text("\(Int((appUpdateManager.installProgress * 100).rounded()))%")
.font(.caption2)
.foregroundStyle(.secondary)
}
}
if let installMessage = appUpdateManager.installMessage {
@ -140,31 +147,43 @@ struct AppUpdaterDialog: View {
HStack {
switch appUpdateManager.status {
case .updateAvailable:
Button("Skip This Version") {
appUpdateManager.skipCurrentVersion()
isPresented = false
}
if appUpdateManager.awaitingInstallCompletionAction {
Spacer()
Button("Remind Me Tomorrow") {
appUpdateManager.remindMeTomorrow()
isPresented = false
}
Spacer()
Button("Download Update") {
appUpdateManager.openDownloadPage()
isPresented = false
}
Button("Install Now") {
Task {
await appUpdateManager.installUpdateNow()
Button("Install & Close App") {
appUpdateManager.completeInstalledUpdate(restart: false)
}
}
.buttonStyle(.borderedProminent)
.disabled(appUpdateManager.isInstalling)
Button("Restart App") {
appUpdateManager.completeInstalledUpdate(restart: true)
}
.buttonStyle(.borderedProminent)
} else {
Button("Skip This Version") {
appUpdateManager.skipCurrentVersion()
isPresented = false
}
Button("Remind Me Tomorrow") {
appUpdateManager.remindMeTomorrow()
isPresented = false
}
Spacer()
Button("Download Update") {
appUpdateManager.openDownloadPage()
isPresented = false
}
Button("Install Now") {
Task {
await appUpdateManager.installUpdateNow()
}
}
.buttonStyle(.borderedProminent)
.disabled(appUpdateManager.isInstalling)
}
case .failed:
Button("Close") {
isPresented = false

View file

@ -1838,9 +1838,9 @@ struct ContentView: View {
highlightRefreshToken: highlightRefreshToken
)
.id(currentLanguage)
.frame(maxWidth: viewModel.isBrainDumpMode ? 800 : .infinity)
.frame(maxWidth: viewModel.isBrainDumpMode ? 920 : .infinity)
.frame(maxHeight: .infinity)
.padding(.horizontal, viewModel.isBrainDumpMode ? 100 : 0)
.padding(.horizontal, viewModel.isBrainDumpMode ? 24 : 0)
.padding(.vertical, viewModel.isBrainDumpMode ? 40 : 0)
.background(
Group {
@ -1856,6 +1856,11 @@ struct ContentView: View {
wordCountView
}
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: viewModel.isBrainDumpMode ? .top : .topLeading
)
if showProjectStructureSidebar && !viewModel.isBrainDumpMode {
Divider()
@ -1872,6 +1877,15 @@ struct ContentView: View {
.frame(minWidth: 220, idealWidth: 260, maxWidth: 340)
}
}
.background(
Group {
if viewModel.isBrainDumpMode && enableTranslucentWindow {
Color.clear.background(.ultraThinMaterial)
} else {
Color.clear
}
}
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
let withEvents = withTypingEvents(

View file

@ -1845,6 +1845,9 @@ struct CustomTextEditor: NSViewRepresentable {
tv.textStorage?.beginEditing()
// Clear previous coloring and apply base color
tv.textStorage?.removeAttribute(.foregroundColor, range: fullRange)
// Clear previous background/underline artifacts so caret-line highlight doesn't accumulate.
tv.textStorage?.removeAttribute(.backgroundColor, range: fullRange)
tv.textStorage?.removeAttribute(.underlineStyle, range: fullRange)
tv.textStorage?.addAttribute(.foregroundColor, value: baseColor, range: fullRange)
// Apply colored ranges
for (range, color) in coloredRanges {

View file

@ -155,7 +155,8 @@ struct NeonSettingsView: View {
.background(
SettingsWindowConfigurator(
minSize: NSSize(width: 900, height: 820),
idealSize: NSSize(width: 980, height: 880)
idealSize: NSSize(width: 980, height: 880),
translucentEnabled: supportsTranslucency && translucentWindow
)
)
#endif
@ -1056,6 +1057,7 @@ final class FontPickerController: NSObject, NSFontChanging {
struct SettingsWindowConfigurator: NSViewRepresentable {
let minSize: NSSize
let idealSize: NSSize
let translucentEnabled: Bool
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
@ -1077,11 +1079,27 @@ struct SettingsWindowConfigurator: NSViewRepresentable {
width: max(window.minSize.width, minSize.width),
height: max(window.minSize.height, minSize.height)
)
// Match native macOS Settings layout: centered preference tabs and hidden title text.
window.toolbarStyle = .preference
window.titleVisibility = .hidden
let targetWidth = max(window.frame.size.width, idealSize.width)
let targetHeight = max(window.frame.size.height, idealSize.height)
if targetWidth != window.frame.size.width || targetHeight != window.frame.size.height {
window.setContentSize(NSSize(width: targetWidth, height: targetHeight))
}
// Keep settings-window translucency in sync without relying on editor view events.
window.isOpaque = !translucentEnabled
window.backgroundColor = translucentEnabled ? .clear : NSColor.windowBackgroundColor
window.titlebarAppearsTransparent = translucentEnabled
if translucentEnabled {
window.styleMask.insert(.fullSizeContentView)
} else {
window.styleMask.remove(.fullSizeContentView)
}
if #available(macOS 13.0, *) {
window.titlebarSeparatorStyle = translucentEnabled ? .none : .automatic
}
}
}
#endif

View file

@ -17,7 +17,7 @@
</p>
> Status: **active release**
> Latest release: **v0.4.14**
> Latest release: **v0.4.15**
> Platform target: **macOS 26 (Tahoe)** compatible with **macOS Sequoia**
> Apple Silicon: tested / Intel: not tested
@ -25,7 +25,7 @@
Prebuilt binaries are available on [GitHub Releases](https://github.com/h3pdesign/Neon-Vision-Editor/releases).
- Latest release: **v0.4.14**
- Latest release: **v0.4.15**
- TestFlight beta: [Join here](https://testflight.apple.com/join/YWB2fGAP)
- Architecture: Apple Silicon (Intel not tested)
- Notarization: *is finally there*
@ -122,6 +122,10 @@ If macOS blocks first launch:
## Changelog
### v0.4.15 (summary)
- Fixed the editor `Highlight Current Line` behavior on macOS so previous line background highlights are cleared and only the active line remains highlighted.
### v0.4.14 (summary)
- Added centralized theme canonicalization with an explicit `Custom` option in settings so legacy/case-variant values resolve consistently across launches.
@ -164,12 +168,12 @@ Full release history: [`CHANGELOG.md`](CHANGELOG.md)
## Release Integrity
- Tag: `v0.4.14`
- Tag: `v0.4.15`
- Tagged commit: `TBD`
- Verify local tag target:
```bash
git rev-parse --verify v0.4.14
git rev-parse --verify v0.4.15
```
- Verify downloaded artifact checksum locally: