@@ -58,3 +58,8 @@ vault.json
|
||||
*.pem
|
||||
*.key
|
||||
!build/appicon.png
|
||||
|
||||
|
||||
# Builds
|
||||
build/releases/v*
|
||||
build/releases/manifest*
|
||||
|
||||
+5
-1
@@ -269,7 +269,11 @@
|
||||
<span id="status-protocol"></span>
|
||||
<span id="status-session-count">0 sessions</span>
|
||||
<span class="status-spacer"></span>
|
||||
<button class="status-update is-hidden" id="status-update" type="button">
|
||||
<button
|
||||
class="status-update is-hidden"
|
||||
id="status-update"
|
||||
type="button"
|
||||
>
|
||||
Update available
|
||||
</button>
|
||||
<span id="status-vault">🔒 Locked</span>
|
||||
|
||||
+104
-93
@@ -7,170 +7,181 @@
|
||||
Main application grid
|
||||
----------------------------------------------------------------------- */
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: var(--tab-bar-height) 1fr var(--status-bar-height);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-width) 1fr;
|
||||
grid-template-rows: var(--tab-bar-height) 1fr var(--status-bar-height);
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
}
|
||||
|
||||
/* Collapsed sidebar variant */
|
||||
.app.sidebar-collapsed {
|
||||
grid-template-columns: var(--sidebar-width-collapsed) 1fr;
|
||||
grid-template-columns: var(--sidebar-width-collapsed) 1fr;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Sidebar
|
||||
----------------------------------------------------------------------- */
|
||||
.sidebar {
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: var(--z-sticky);
|
||||
grid-row: 1 / -1;
|
||||
grid-column: 1;
|
||||
background: var(--bg-surface);
|
||||
border-right: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Tab bar
|
||||
----------------------------------------------------------------------- */
|
||||
.tab-bar {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--tab-bar-height);
|
||||
overflow: hidden;
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
background: var(--bg-surface);
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: var(--tab-bar-height);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.tab-list::-webkit-scrollbar {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Workspace
|
||||
----------------------------------------------------------------------- */
|
||||
.workspace {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg-base);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Panel container — fills workspace, holds session panels */
|
||||
.panel-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Individual session panel — one per tab, fills the panel container */
|
||||
.session-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Terminal container — fills workspace */
|
||||
.terminal-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Status bar
|
||||
----------------------------------------------------------------------- */
|
||||
.status-bar {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: var(--space-4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
background: var(--bg-surface);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
gap: var(--space-4);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-bar span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.status-spacer {
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-update {
|
||||
border: 1px solid var(--accent);
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: background 150ms ease, color 150ms ease, border-color 150ms ease;
|
||||
border: 1px solid var(--accent);
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: var(--text-xs);
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 150ms ease,
|
||||
color 150ms ease,
|
||||
border-color 150ms ease;
|
||||
}
|
||||
|
||||
.status-update:hover {
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-strong, var(--accent));
|
||||
background: rgba(78, 205, 196, 0.2);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent-strong, var(--accent));
|
||||
}
|
||||
|
||||
.status-update.is-hidden {
|
||||
display: none;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Status bar indicator dots */
|
||||
.status-bar .status-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.status-dot.is-connected {
|
||||
background: var(--success);
|
||||
}
|
||||
.status-dot.is-connecting {
|
||||
background: var(--warning);
|
||||
}
|
||||
.status-dot.is-error {
|
||||
background: var(--error);
|
||||
}
|
||||
.status-dot.is-disconnected {
|
||||
background: var(--text-tertiary);
|
||||
}
|
||||
.status-dot.is-connected { background: var(--success); }
|
||||
.status-dot.is-connecting { background: var(--warning); }
|
||||
.status-dot.is-error { background: var(--error); }
|
||||
.status-dot.is-disconnected { background: var(--text-tertiary); }
|
||||
|
||||
/* -----------------------------------------------------------------------
|
||||
Resize handle (between sidebar and workspace)
|
||||
----------------------------------------------------------------------- */
|
||||
.resize-handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: var(--z-sticky);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -3px;
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
cursor: col-resize;
|
||||
z-index: var(--z-sticky);
|
||||
}
|
||||
.resize-handle:hover,
|
||||
.resize-handle.is-dragging {
|
||||
background: var(--accent-muted);
|
||||
background: var(--accent-muted);
|
||||
}
|
||||
|
||||
@@ -513,7 +513,9 @@
|
||||
const updateBtn = document.getElementById("status-update");
|
||||
if (updateBtn) {
|
||||
updateBtn.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
updateBtn.addEventListener("click", () => this.settings.openTab("about"));
|
||||
updateBtn.addEventListener("click", () =>
|
||||
this.settings.openTab("about"),
|
||||
);
|
||||
}
|
||||
|
||||
const searchInput = document.getElementById("profile-search");
|
||||
|
||||
@@ -36,8 +36,7 @@ export namespace models {
|
||||
}
|
||||
export class AuditEntry {
|
||||
id: string;
|
||||
// Go type: time
|
||||
timestamp: any;
|
||||
timestamp: string;
|
||||
action: string;
|
||||
profileId?: string;
|
||||
sessionId?: string;
|
||||
@@ -50,30 +49,12 @@ export namespace models {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.timestamp = this.convertValues(source["timestamp"], null);
|
||||
this.timestamp = source["timestamp"];
|
||||
this.action = source["action"];
|
||||
this.profileId = source["profileId"];
|
||||
this.sessionId = source["sessionId"];
|
||||
this.details = source["details"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class GitHubConfig {
|
||||
baseURL: string;
|
||||
@@ -160,10 +141,8 @@ export namespace models {
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
publicKey: string;
|
||||
// Go type: time
|
||||
firstSeen: any;
|
||||
// Go type: time
|
||||
lastSeen: any;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
trusted: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -178,28 +157,10 @@ export namespace models {
|
||||
this.keyType = source["keyType"];
|
||||
this.fingerprint = source["fingerprint"];
|
||||
this.publicKey = source["publicKey"];
|
||||
this.firstSeen = this.convertValues(source["firstSeen"], null);
|
||||
this.lastSeen = this.convertValues(source["lastSeen"], null);
|
||||
this.firstSeen = source["firstSeen"];
|
||||
this.lastSeen = source["lastSeen"];
|
||||
this.trusted = source["trusted"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class LoginScriptStep {
|
||||
type: string;
|
||||
@@ -436,14 +397,11 @@ export namespace models {
|
||||
environment: string;
|
||||
notes: string;
|
||||
favourite: boolean;
|
||||
// Go type: time
|
||||
lastConnected?: any;
|
||||
lastConnected?: string;
|
||||
connectTimeout: number;
|
||||
readTimeout: number;
|
||||
// Go type: time
|
||||
createdAt: any;
|
||||
// Go type: time
|
||||
updatedAt: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
terminalFontFamily: string;
|
||||
terminalFontSize: number;
|
||||
terminalScrollback: number;
|
||||
@@ -471,11 +429,11 @@ export namespace models {
|
||||
this.environment = source["environment"];
|
||||
this.notes = source["notes"];
|
||||
this.favourite = source["favourite"];
|
||||
this.lastConnected = this.convertValues(source["lastConnected"], null);
|
||||
this.lastConnected = source["lastConnected"];
|
||||
this.connectTimeout = source["connectTimeout"];
|
||||
this.readTimeout = source["readTimeout"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], null);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], null);
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
this.terminalFontFamily = source["terminalFontFamily"];
|
||||
this.terminalFontSize = source["terminalFontSize"];
|
||||
this.terminalScrollback = source["terminalScrollback"];
|
||||
@@ -516,8 +474,7 @@ export namespace models {
|
||||
state: string;
|
||||
tabName: string;
|
||||
pinned: boolean;
|
||||
// Go type: time
|
||||
connectedAt: any;
|
||||
connectedAt: string;
|
||||
error?: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -535,27 +492,9 @@ export namespace models {
|
||||
this.state = source["state"];
|
||||
this.tabName = source["tabName"];
|
||||
this.pinned = source["pinned"];
|
||||
this.connectedAt = this.convertValues(source["connectedAt"], null);
|
||||
this.connectedAt = source["connectedAt"];
|
||||
this.error = source["error"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class Snippet {
|
||||
id: string;
|
||||
@@ -564,10 +503,8 @@ export namespace models {
|
||||
description: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
// Go type: time
|
||||
createdAt: any;
|
||||
// Go type: time
|
||||
updatedAt: any;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new Snippet(source);
|
||||
@@ -581,27 +518,9 @@ export namespace models {
|
||||
this.description = source["description"];
|
||||
this.folder = source["folder"];
|
||||
this.tags = source["tags"];
|
||||
this.createdAt = this.convertValues(source["createdAt"], null);
|
||||
this.updatedAt = this.convertValues(source["updatedAt"], null);
|
||||
this.createdAt = source["createdAt"];
|
||||
this.updatedAt = source["updatedAt"];
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
-1
@@ -1306,7 +1306,10 @@ func (s *Service) CheckForUpdates() (map[string]interface{}, error) {
|
||||
var err error
|
||||
|
||||
if appUpdateURL != "" {
|
||||
checker := updater.NewURLChecker(appUpdateURL, appVersion)
|
||||
url := strings.ReplaceAll(appUpdateURL, "{platform}", runtime.GOOS)
|
||||
url = strings.ReplaceAll(url, "{os}", runtime.GOOS)
|
||||
url = strings.ReplaceAll(url, "{arch}", runtime.GOARCH)
|
||||
checker := updater.NewURLChecker(url, appVersion)
|
||||
info, err = checker.Check(ctx)
|
||||
} else {
|
||||
checker := updater.NewChecker(appVersion)
|
||||
|
||||
@@ -57,11 +57,11 @@ type Profile struct {
|
||||
Environment string `json:"environment"`
|
||||
Notes string `json:"notes"`
|
||||
Favourite bool `json:"favourite"`
|
||||
LastConnected *time.Time `json:"lastConnected"`
|
||||
LastConnected *time.Time `json:"lastConnected" ts_type:"string"`
|
||||
ConnectTimeout int `json:"connectTimeout"` // seconds
|
||||
ReadTimeout int `json:"readTimeout"` // seconds
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt" ts_type:"string"`
|
||||
UpdatedAt time.Time `json:"updatedAt" ts_type:"string"`
|
||||
|
||||
// Terminal preferences
|
||||
TerminalFontFamily string `json:"terminalFontFamily"`
|
||||
@@ -233,7 +233,7 @@ type SessionInfo struct {
|
||||
State SessionState `json:"state"`
|
||||
TabName string `json:"tabName"`
|
||||
Pinned bool `json:"pinned"`
|
||||
ConnectedAt time.Time `json:"connectedAt"`
|
||||
ConnectedAt time.Time `json:"connectedAt" ts_type:"string"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
@@ -245,8 +245,8 @@ type KnownHost struct {
|
||||
KeyType string `json:"keyType"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
FirstSeen time.Time `json:"firstSeen"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
FirstSeen time.Time `json:"firstSeen" ts_type:"string"`
|
||||
LastSeen time.Time `json:"lastSeen" ts_type:"string"`
|
||||
Trusted bool `json:"trusted"`
|
||||
}
|
||||
|
||||
@@ -269,7 +269,7 @@ type AppPreferences struct {
|
||||
// AuditEntry records an auditable action within the application.
|
||||
type AuditEntry struct {
|
||||
ID string `json:"id"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Timestamp time.Time `json:"timestamp" ts_type:"string"`
|
||||
Action string `json:"action"`
|
||||
ProfileID string `json:"profileId,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
@@ -284,6 +284,6 @@ type Snippet struct {
|
||||
Description string `json:"description"`
|
||||
Folder string `json:"folder"`
|
||||
Tags []string `json:"tags"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
CreatedAt time.Time `json:"createdAt" ts_type:"string"`
|
||||
UpdatedAt time.Time `json:"updatedAt" ts_type:"string"`
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"github.com/wailsapp/wails/v2/pkg/options/mac"
|
||||
"github.com/wailsapp/wails/v2/pkg/options/windows"
|
||||
|
||||
application "github.com/andydixon/nexus/internal/app"
|
||||
"github.com/andydixon/nexus/frontend"
|
||||
application "github.com/andydixon/nexus/internal/app"
|
||||
)
|
||||
|
||||
func buildAppMenu(app *application.App) *menu.Menu {
|
||||
|
||||
Regular → Executable
+131
-50
@@ -11,6 +11,9 @@ YELLOW='\033[1;33m'
|
||||
RED='\033[0;31m'
|
||||
NC='\033[0m'
|
||||
|
||||
DEFAULT_UPDATE_URL="https://dixon.cx/downloads/nexus/manifest-{platform}-{arch}.json"
|
||||
DEFAULT_RELEASE_BASE_URL="https://dixon.cx/downloads/nexus"
|
||||
|
||||
log() { echo -e "${GREEN}[nexus]${NC} $1"; }
|
||||
warn() { echo -e "${YELLOW}[nexus]${NC} $1"; }
|
||||
err() { echo -e "${RED}[nexus]${NC} $1" >&2; }
|
||||
@@ -62,6 +65,10 @@ pick_cc() {
|
||||
local target_arch="$2"
|
||||
|
||||
if [ "$target_os" = "windows" ]; then
|
||||
if command -v zig >/dev/null 2>&1; then
|
||||
echo "zig:${target_arch}"
|
||||
return
|
||||
fi
|
||||
if [ "$target_arch" = "amd64" ]; then
|
||||
echo "x86_64-w64-mingw32-gcc"
|
||||
else
|
||||
@@ -71,6 +78,10 @@ pick_cc() {
|
||||
fi
|
||||
|
||||
if [ "$target_os" = "linux" ]; then
|
||||
if command -v zig >/dev/null 2>&1; then
|
||||
echo "zig:${target_arch}"
|
||||
return
|
||||
fi
|
||||
if [ "$target_arch" = "amd64" ]; then
|
||||
echo "x86_64-linux-gnu-gcc"
|
||||
else
|
||||
@@ -92,6 +103,17 @@ build_target() {
|
||||
local cc
|
||||
cc="$(pick_cc "$target_os" "$target_arch")"
|
||||
|
||||
local zig_target=""
|
||||
if [[ "$cc" == zig:* ]]; then
|
||||
zig_target="${cc#zig:}"
|
||||
if [ "$zig_target" = "amd64" ]; then
|
||||
zig_target="x86_64"
|
||||
elif [ "$zig_target" = "arm64" ]; then
|
||||
zig_target="aarch64"
|
||||
fi
|
||||
cc="zig"
|
||||
fi
|
||||
|
||||
if [ -n "$cc" ] && ! command -v "$cc" >/dev/null 2>&1; then
|
||||
warn "Skipping ${platform} (missing C compiler: ${cc})"
|
||||
return 0
|
||||
@@ -111,18 +133,75 @@ build_target() {
|
||||
local ldflags
|
||||
ldflags="$(build_ldflags "$version")"
|
||||
if [ -n "$cc" ]; then
|
||||
CC="$cc" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"
|
||||
local cc_cmd="$cc"
|
||||
if [ -n "$zig_target" ]; then
|
||||
if [ "$target_os" = "linux" ]; then
|
||||
cc_cmd="zig cc -target ${zig_target}-linux-gnu"
|
||||
elif [ "$target_os" = "windows" ]; then
|
||||
cc_cmd="zig cc -target ${zig_target}-windows-gnu"
|
||||
fi
|
||||
fi
|
||||
if ! CC="$cc_cmd" CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
|
||||
warn "Build failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"
|
||||
if ! CGO_ENABLED=1 wails build -platform "$platform" -ldflags "$ldflags" -o "$output_path"; then
|
||||
warn "Build failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
local zip_name="${out_dir}/${output_base}.zip"
|
||||
local expected_path=""
|
||||
local build_bin="build/bin"
|
||||
if [ "$target_os" = "darwin" ]; then
|
||||
(cd "$out_dir" && zip -r "${output_base}.zip" "${output_base}.app" >/dev/null)
|
||||
expected_path="${out_dir}/${output_base}.app"
|
||||
if [ ! -d "$expected_path" ]; then
|
||||
local source_app
|
||||
source_app="$(find "$build_bin" -type d -name "*.app" -print0 | xargs -0 ls -td 2>/dev/null | head -1)"
|
||||
if [ -z "$source_app" ]; then
|
||||
warn "Built .app not found for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
log "Using app bundle: ${source_app}"
|
||||
cp -R "$source_app" "$expected_path"
|
||||
fi
|
||||
if ! (cd "$out_dir" && zip -r "${output_base}.zip" "${output_base}.app" >/dev/null); then
|
||||
warn "Zip failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
elif [ "$target_os" = "windows" ]; then
|
||||
(cd "$out_dir" && zip -j "${output_base}.zip" "${output_base}.exe" >/dev/null)
|
||||
expected_path="${out_dir}/${output_base}.exe"
|
||||
if [ ! -f "$expected_path" ]; then
|
||||
local source_exe
|
||||
source_exe="$(find "$build_bin" -type f -name "${output_base}.exe" -print0 | xargs -0 ls -t 2>/dev/null | head -1)"
|
||||
if [ -z "$source_exe" ]; then
|
||||
warn "Built .exe not found for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
log "Using executable: ${source_exe}"
|
||||
cp "$source_exe" "$expected_path"
|
||||
fi
|
||||
if ! (cd "$out_dir" && zip -j "${output_base}.zip" "${output_base}.exe" >/dev/null); then
|
||||
warn "Zip failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
(cd "$out_dir" && zip -j "${output_base}.zip" "${output_base}" >/dev/null)
|
||||
expected_path="${out_dir}/${output_base}"
|
||||
if [ ! -f "$expected_path" ]; then
|
||||
local source_bin
|
||||
source_bin="$(find "$build_bin" -type f -name "${output_base}" -print0 | xargs -0 ls -t 2>/dev/null | head -1)"
|
||||
if [ -z "$source_bin" ] || [ ! -f "$source_bin" ]; then
|
||||
warn "Built binary not found for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
log "Using binary: ${source_bin}"
|
||||
cp "$source_bin" "$expected_path"
|
||||
fi
|
||||
if ! (cd "$out_dir" && zip -j "${output_base}.zip" "${output_base}" >/dev/null); then
|
||||
warn "Zip failed for ${platform}"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "$target_os|$target_arch|$output_base.zip" >> "${out_dir}/.release-files"
|
||||
@@ -131,49 +210,44 @@ build_target() {
|
||||
write_manifest() {
|
||||
local version="$1"
|
||||
local out_dir="$2"
|
||||
local manifest_path="$3"
|
||||
local version_path="$4"
|
||||
local release_root="$3"
|
||||
local base_url="${NEXUS_RELEASE_BASE_URL:-}"
|
||||
|
||||
local published_at
|
||||
published_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
|
||||
echo "$version" > "$version_path"
|
||||
|
||||
{
|
||||
echo "{"
|
||||
echo " \"latestVersion\": \"${version}\","
|
||||
echo " \"publishedAt\": \"${published_at}\","
|
||||
while IFS='|' read -r os arch filename; do
|
||||
[ -z "$os" ] && continue
|
||||
local manifest_path="${release_root}/manifest-${os}-${arch}.json"
|
||||
local version_path="${release_root}/version-${os}-${arch}.txt"
|
||||
local file_path="${out_dir}/${filename}"
|
||||
local sha
|
||||
sha="$(shasum -a 256 "$file_path" | awk '{print $1}')"
|
||||
local size
|
||||
size="$(wc -c < "$file_path" | tr -d ' ')"
|
||||
local url=""
|
||||
if [ -n "$base_url" ]; then
|
||||
echo " \"releaseURL\": \"${base_url}/\","
|
||||
else
|
||||
echo " \"releaseURL\": \"\","
|
||||
url="${base_url}/${filename}"
|
||||
fi
|
||||
echo " \"files\": ["
|
||||
|
||||
local first=1
|
||||
while IFS='|' read -r os arch filename; do
|
||||
[ -z "$os" ] && continue
|
||||
local file_path="${out_dir}/${filename}"
|
||||
local sha
|
||||
sha="$(shasum -a 256 "$file_path" | awk '{print $1}')"
|
||||
local size
|
||||
size="$(wc -c < "$file_path" | tr -d ' ')"
|
||||
local url=""
|
||||
echo "$version" > "$version_path"
|
||||
|
||||
{
|
||||
echo "{"
|
||||
echo " \"latestVersion\": \"${version}\","
|
||||
echo " \"publishedAt\": \"${published_at}\","
|
||||
if [ -n "$base_url" ]; then
|
||||
url="${base_url}/${filename}"
|
||||
echo " \"releaseURL\": \"${base_url}/\","
|
||||
else
|
||||
echo " \"releaseURL\": \"\","
|
||||
fi
|
||||
|
||||
if [ $first -eq 0 ]; then
|
||||
echo ","
|
||||
fi
|
||||
first=0
|
||||
echo " \"files\": ["
|
||||
printf " {\"platform\":\"%s\",\"arch\":\"%s\",\"filename\":\"%s\",\"url\":\"%s\",\"sha256\":\"%s\",\"size\":%s}" "$os" "$arch" "$filename" "$url" "$sha" "$size"
|
||||
done < "${out_dir}/.release-files"
|
||||
echo
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} > "$manifest_path"
|
||||
echo
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} > "$manifest_path"
|
||||
done < "${out_dir}/.release-files"
|
||||
|
||||
rm -f "${out_dir}/.release-files"
|
||||
}
|
||||
@@ -186,7 +260,10 @@ main() {
|
||||
version="$(build_version)"
|
||||
|
||||
if [ -z "${NEXUS_UPDATE_URL:-}" ]; then
|
||||
warn "NEXUS_UPDATE_URL is not set; updates will default to GitHub in this build"
|
||||
NEXUS_UPDATE_URL="${DEFAULT_UPDATE_URL}"
|
||||
fi
|
||||
if [ -z "${NEXUS_RELEASE_BASE_URL:-}" ]; then
|
||||
NEXUS_RELEASE_BASE_URL="${DEFAULT_RELEASE_BASE_URL}"
|
||||
fi
|
||||
|
||||
local release_root="build/releases"
|
||||
@@ -196,20 +273,24 @@ main() {
|
||||
log "Building release ${version}..."
|
||||
: > "${out_dir}/.release-files"
|
||||
|
||||
build_target "darwin" "amd64" "$version" "$out_dir"
|
||||
build_target "darwin" "arm64" "$version" "$out_dir"
|
||||
build_target "linux" "amd64" "$version" "$out_dir"
|
||||
build_target "linux" "arm64" "$version" "$out_dir"
|
||||
build_target "windows" "amd64" "$version" "$out_dir"
|
||||
build_target "windows" "arm64" "$version" "$out_dir"
|
||||
local errors=0
|
||||
if ! build_target "darwin" "amd64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "darwin" "arm64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "linux" "amd64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "linux" "arm64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "windows" "amd64" "$version" "$out_dir"; then errors=1; fi
|
||||
if ! build_target "windows" "arm64" "$version" "$out_dir"; then errors=1; fi
|
||||
|
||||
local manifest_path="${release_root}/manifest.json"
|
||||
local version_path="${release_root}/version.txt"
|
||||
write_manifest "$version" "$out_dir" "$manifest_path" "$version_path"
|
||||
write_manifest "$version" "$out_dir" "$release_root"
|
||||
|
||||
log "Release artefacts written to ${out_dir}"
|
||||
log "Manifest: ${manifest_path}"
|
||||
log "Version: ${version_path}"
|
||||
log "Manifests: ${release_root}/manifest-<platform>-<arch>.json"
|
||||
log "Versions: ${release_root}/version-<platform>-<arch>.txt"
|
||||
|
||||
if [ $errors -ne 0 ]; then
|
||||
err "One or more targets failed. Manifest and version were still generated."
|
||||
exit 12
|
||||
fi
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
<?php
|
||||
$platform = isset($_GET['platform']) ? $_GET['platform'] : '';
|
||||
$arch = isset($_GET['arch']) ? $_GET['arch'] : '';
|
||||
$platform = preg_replace('/[^a-z0-9_-]/i', '', $platform);
|
||||
$arch = preg_replace('/[^a-z0-9_-]/i', '', $arch);
|
||||
|
||||
$manifestPath = __DIR__ . '/manifest.json';
|
||||
$versionPath = __DIR__ . '/version.txt';
|
||||
$manifestFiles = glob(__DIR__ . '/manifest-*-*.json') ?: [];
|
||||
if ($platform && $arch) {
|
||||
$manifestPath = __DIR__ . '/manifest-' . $platform . '-' . $arch . '.json';
|
||||
$versionPath = __DIR__ . '/version-' . $platform . '-' . $arch . '.txt';
|
||||
}
|
||||
|
||||
$accept = isset($_SERVER['HTTP_ACCEPT']) ? $_SERVER['HTTP_ACCEPT'] : '';
|
||||
$format = isset($_GET['format']) ? strtolower($_GET['format']) : '';
|
||||
|
||||
$wantsJson = $format === 'json' || strpos($accept, 'application/json') !== false;
|
||||
$wantsVersion = $format === 'version';
|
||||
|
||||
$manifest = null;
|
||||
if (is_readable($manifestPath)) {
|
||||
$raw = file_get_contents($manifestPath);
|
||||
$decoded = json_decode($raw, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$manifest = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
$allManifests = [];
|
||||
foreach ($manifestFiles as $path) {
|
||||
$raw = file_get_contents($path);
|
||||
$decoded = json_decode($raw, true);
|
||||
if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) {
|
||||
$allManifests[] = $decoded;
|
||||
}
|
||||
}
|
||||
|
||||
if ($wantsJson) {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
if ($manifest) {
|
||||
echo json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
} else {
|
||||
http_response_code(503);
|
||||
echo json_encode(['error' => 'manifest unavailable'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($wantsVersion) {
|
||||
header('Content-Type: text/plain; charset=utf-8');
|
||||
if ($manifest && !empty($manifest['latestVersion'])) {
|
||||
echo $manifest['latestVersion'];
|
||||
} elseif (is_readable($versionPath)) {
|
||||
echo trim(file_get_contents($versionPath));
|
||||
} else {
|
||||
http_response_code(503);
|
||||
echo 'unknown';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($platform && $arch && $format === 'download') {
|
||||
if ($manifest && !empty($manifest['files'][0]['url'])) {
|
||||
header('Location: ' . $manifest['files'][0]['url'], true, 302);
|
||||
} else {
|
||||
http_response_code(404);
|
||||
echo 'download not available';
|
||||
}
|
||||
exit;
|
||||
}
|
||||
|
||||
function h($value)
|
||||
{
|
||||
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
function formatSize($bytes)
|
||||
{
|
||||
if (!is_numeric($bytes)) {
|
||||
return '';
|
||||
}
|
||||
$bytes = (float)$bytes;
|
||||
$units = ['B', 'KB', 'MB', 'GB'];
|
||||
$i = 0;
|
||||
while ($bytes >= 1024 && $i < count($units) - 1) {
|
||||
$bytes /= 1024;
|
||||
$i++;
|
||||
}
|
||||
return number_format($bytes, $i === 0 ? 0 : 2) . ' ' . $units[$i];
|
||||
}
|
||||
|
||||
$latest = $manifest && isset($manifest['latestVersion']) ? $manifest['latestVersion'] : 'unknown';
|
||||
$published = $manifest && isset($manifest['publishedAt']) ? $manifest['publishedAt'] : '';
|
||||
$platformLabel = $platform && $arch ? strtoupper($platform) . ' / ' . $arch : '';
|
||||
$files = $manifest && isset($manifest['files']) && is_array($manifest['files']) ? $manifest['files'] : [];
|
||||
|
||||
$rows = [];
|
||||
if (!$platform && !$arch) {
|
||||
foreach ($allManifests as $entry) {
|
||||
$file = isset($entry['files'][0]) ? $entry['files'][0] : null;
|
||||
if (!$file) {
|
||||
continue;
|
||||
}
|
||||
$rows[] = [
|
||||
'platform' => isset($file['platform']) ? $file['platform'] : '',
|
||||
'arch' => isset($file['arch']) ? $file['arch'] : '',
|
||||
'version' => isset($entry['latestVersion']) ? $entry['latestVersion'] : 'unknown',
|
||||
'size' => isset($file['size']) ? $file['size'] : '',
|
||||
'url' => isset($file['url']) ? $file['url'] : '',
|
||||
'filename' => isset($file['filename']) ? $file['filename'] : '',
|
||||
];
|
||||
}
|
||||
usort($rows, function ($a, $b) {
|
||||
$ka = $a['platform'] . '/' . $a['arch'] . '/' . $a['version'];
|
||||
$kb = $b['platform'] . '/' . $b['arch'] . '/' . $b['version'];
|
||||
return strcmp($ka, $kb);
|
||||
});
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Nexus Terminal Downloads</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #111318;
|
||||
--panel: #1b1e24;
|
||||
--text: #e7e9ee;
|
||||
--muted: #9aa2b2;
|
||||
--accent: #4ecdc4;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 60px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid #2a2f3a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 10px 8px;
|
||||
border-bottom: 1px solid #2a2f3a;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(78, 205, 196, 0.12);
|
||||
color: var(--accent);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>Nexus Terminal Downloads</h1>
|
||||
<div class="meta">Latest version: <span class="badge"><?php echo h($latest); ?></span><?php if ($platformLabel) {
|
||||
echo ' • ' . h($platformLabel);
|
||||
} ?><?php if ($published) {
|
||||
echo ' • Published ' . h($published);
|
||||
} ?></div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="margin:0 0 10px;font-size:18px;">Release files</h2>
|
||||
<?php if (!$platform && !$arch) { ?>
|
||||
<?php if (empty($rows)) { ?>
|
||||
<div class="empty">No release files available.</div>
|
||||
<?php } else { ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<th>CPU</th>
|
||||
<th>Version</th>
|
||||
<th>Size</th>
|
||||
<th>Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $row) { ?>
|
||||
<?php
|
||||
$downloadLink = '';
|
||||
if ($row['platform'] && $row['arch']) {
|
||||
$downloadLink = '?platform=' . urlencode($row['platform']) . '&arch=' . urlencode($row['arch']) . '&format=download';
|
||||
}
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo h($row['platform']); ?></td>
|
||||
<td><?php echo h($row['arch']); ?></td>
|
||||
<td><?php echo h($row['version']); ?></td>
|
||||
<td><?php echo h(formatSize($row['size'])); ?></td>
|
||||
<td>
|
||||
<?php if ($downloadLink) { ?>
|
||||
<a href="<?php echo h($downloadLink); ?>">Download</a>
|
||||
<?php } else { ?>
|
||||
<?php echo h($row['filename']); ?>
|
||||
<?php } ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php } ?>
|
||||
<?php } else { ?>
|
||||
<?php if (empty($files)) { ?>
|
||||
<div class="empty">No release files available.</div>
|
||||
<?php } else { ?>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<th>Arch</th>
|
||||
<th>File</th>
|
||||
<th>Size</th>
|
||||
<th>SHA-256</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($files as $file) {
|
||||
$platform = isset($file['platform']) ? $file['platform'] : '';
|
||||
$arch = isset($file['arch']) ? $file['arch'] : '';
|
||||
$filename = isset($file['filename']) ? $file['filename'] : '';
|
||||
$url = isset($file['url']) ? $file['url'] : '';
|
||||
$size = isset($file['size']) ? $file['size'] : '';
|
||||
$sha = isset($file['sha256']) ? $file['sha256'] : '';
|
||||
?>
|
||||
<tr>
|
||||
<td><?php echo h($platform); ?></td>
|
||||
<td><?php echo h($arch); ?></td>
|
||||
<td>
|
||||
<?php if ($url) { ?>
|
||||
<a href="<?php echo h($url); ?>"><?php echo h($filename); ?></a>
|
||||
<?php } else { ?>
|
||||
<?php echo h($filename); ?>
|
||||
<?php } ?>
|
||||
</td>
|
||||
<td><?php echo h(formatSize($size)); ?></td>
|
||||
<td style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; color: var(--muted);">
|
||||
<?php echo h($sha); ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php } ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php } ?>
|
||||
<?php } ?>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
Reference in New Issue
Block a user