mirror of
https://github.com/dredx/prole.git
synced 2026-09-23 11:03:59 +00:00
Rebrand and consolidate authentication and scanning systems. Consolidate Kerberos authentication system into authority/src and rename ProleAuthApplication to KnoeAuthApplication. Remove deprecated prole/, prole-app/, prole-mssql-db/, and prole-tools-app/ directories. Migrate and rename prole-net/prole-agent to scan/network-agent. Update install.sh, Makefile, and documentation to use Knoe branding and new scan paths. Rebrand configuration properties and URLs to knoe.dev and svc.knoe.dev.
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
parent
34de8a5f34
commit
f44ce32a27
@ -1,17 +0,0 @@
|
||||
<component name="RunManager">
|
||||
<configuration name="Ansible: Run Site Playbook" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="ansible-playbook infrastructure/playbooks/site.yml --vault-password-file .vault_pass" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/zsh" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@ -1,19 +0,0 @@
|
||||
<component name="RunManager">
|
||||
<configuration name="Silent Install Test" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/tests/silent_install_test.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/bash" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs>
|
||||
<env name="SILENT_INSTALL_LOG" value="true" />
|
||||
</envs>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
115
AGENTS.md
Normal file
115
AGENTS.md
Normal file
@ -0,0 +1,115 @@
|
||||
# AGENTS.md — AI coding agent guide for knoe-db / Prole
|
||||
|
||||
## What this repo is
|
||||
|
||||
**Knoe** is an infrastructure stack for deploying a Supabase-style internal developer platform (PostgreSQL, object storage, secrets, auth, observability) across K3d (local), K3s (on-prem), and GKE (cloud) environments. The Python "Prole" installer (`prole/cli.py`) drives all cluster setup via milestones.
|
||||
|
||||
---
|
||||
|
||||
## Architecture overview
|
||||
|
||||
### Dual-cluster GKE layout (prod)
|
||||
Two GKE clusters in `us-west3`:
|
||||
| Cluster | Context | Purpose |
|
||||
|---|---|---|
|
||||
| `knoe-dev-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-dev-0` | App cluster — Garage, Registry, OpenBao, Kong, GitLab, monitoring |
|
||||
| `knoe-cnpg-0` | `gke_plenary-truck-485623-p7_us-west3_knoe-cnpg-0` | DB cluster — CNPG/PostgreSQL only |
|
||||
|
||||
**Critical:** Garage must NEVER be deployed to `knoe-cnpg-0`. SSD quota (300 GB) is fully consumed by CNPG — all non-CNPG PVCs must use `standard` storage class (HDD), not `standard-rwo`/`premium-rwo`.
|
||||
|
||||
### Deployment environments / modes
|
||||
| `cluster_env` | `PROLE_MODE` | Target |
|
||||
|---|---|---|
|
||||
| `dev` | `k3d` | Local K3d cluster |
|
||||
| `service` | `k3s` | On-prem K3s cluster |
|
||||
| `prod` | `k8s` | GKE (or other cloud) |
|
||||
|
||||
`_deployment_mode_from_env()` in `knoe/core/env.py` converts env strings to mode strings.
|
||||
|
||||
### Config file mapping
|
||||
`knoe/prole_conf.py` maps environments to config files under `conf/`:
|
||||
- `dev` → `k3d.cfg`
|
||||
- `service` → `k3s.cfg`
|
||||
- `prod` → `gke.cfg`
|
||||
|
||||
Config is layered: env-specific file overrides base. `PROLE_CONF` env var or `conf/service/` subdirs point to the active config.
|
||||
|
||||
---
|
||||
|
||||
## Key source locations
|
||||
|
||||
| Path | Purpose |
|
||||
|---|---|
|
||||
| `knoe/core/env.py` | Core config/env helpers, secret encryption, kubeconfig resolution |
|
||||
| `knoe/core/actions.py` | All installer actions and unattended workflow helpers (~8k lines) |
|
||||
| `knoe/milestone.py` | `Milestone` ABC — all install steps implement this; `_get_script_env()` builds the env for subprocesses |
|
||||
| `knoe/prole_conf.py` | Config path resolution and layered loading |
|
||||
| `knoe/core/milestones.py` | Concrete milestone definitions |
|
||||
| `conf/gke.cfg` | Production GKE config (must have correct `app_cluster_kubecontext` / `db_cluster_kubecontext`) |
|
||||
| `conf/service/prod.cfg` | Unattended deploy config for `./deploy.sh` |
|
||||
| `etc/` | Shell init scripts (`init_*.sh`) called by milestones |
|
||||
| `k8s/` | Kubernetes manifests by service |
|
||||
| `scripts/reset_clusters.sh` | Full cluster teardown + recreate |
|
||||
|
||||
---
|
||||
|
||||
## Developer workflows
|
||||
|
||||
### Install dependencies
|
||||
```bash
|
||||
make requirements # pip install -r prole_requirements.txt
|
||||
```
|
||||
|
||||
### Run tests
|
||||
```bash
|
||||
make test # runs pyconv (black check) then pytest with coverage
|
||||
# or directly:
|
||||
PYTHONPATH=. pytest tests/
|
||||
```
|
||||
|
||||
### Build the prole CLI binary
|
||||
```bash
|
||||
make prole # PyInstaller one-file binary → dist/prole
|
||||
```
|
||||
|
||||
### Interactive installer (ncurses)
|
||||
```bash
|
||||
./install.sh # reads conf/gke.cfg (or PROLE_CONF)
|
||||
```
|
||||
|
||||
### Unattended deploy
|
||||
```bash
|
||||
./deploy.sh # reads conf/service/prod.cfg
|
||||
```
|
||||
|
||||
### Code style
|
||||
```bash
|
||||
black . # formatter (black --check . is enforced in CI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Secret handling
|
||||
|
||||
Secrets in `prole.cfg` are AES-GCM encrypted at rest using `${PROLE_SECRET:v1:...}` tokens. On macOS, the key is in Keychain (`prole-installer` service); on Linux, at `~/.prole/secrets/knoe.key`. OpenBao references use `${OPENBAO:kv/prole/<ns>/<leaf>#<key>}`. Never store plaintext passwords in config files.
|
||||
|
||||
---
|
||||
|
||||
## Milestone pattern
|
||||
|
||||
All installer steps subclass `Milestone` (`knoe/milestone.py`). They must:
|
||||
- Be UI-agnostic (no tkinter/ncurses imports)
|
||||
- Use `_run_cmd()` for subprocesses (handles env injection)
|
||||
- Use `_get_script_env(state)` to build env dicts for shell scripts — this is where `KUBECONTEXT`, `DB_CLUSTER_KUBECONTEXT`, `PROLE_CONF`, etc. are set
|
||||
|
||||
Missing `init_cluster.app_cluster_kubecontext` in config causes Garage to deploy to the wrong cluster.
|
||||
|
||||
---
|
||||
|
||||
## CNPG / backup specifics
|
||||
|
||||
- CNPG backups go to **GCS** (not Garage): `gs://knoe-0-backups/` and `gs://knoe-0-wal/`
|
||||
- Workload Identity SA: `cnpg-backup@plenary-truck-485623-p7.iam.gserviceaccount.com`
|
||||
- ObjectStore manifest: `k8s/prole/knoe-db-barman-objectstore-gcs.yaml`
|
||||
- Setup: `etc/init_cnpg_gke.sh` and `etc/init_cnpg_backup.sh`
|
||||
|
||||
2
Makefile
2
Makefile
@ -49,7 +49,7 @@ prole:
|
||||
--exclude-module _tkinter \
|
||||
--exclude-module Tkinter \
|
||||
--add-data "etc:etc" \
|
||||
--add-data "prole-net:prole-net" \
|
||||
--add-data "scan:scan" \
|
||||
--add-data "k8s:k8s" \
|
||||
--add-data "conf:conf" \
|
||||
prole/cli.py
|
||||
|
||||
@ -1,27 +0,0 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
acl \
|
||||
adcli \
|
||||
attr \
|
||||
ca-certificates \
|
||||
curl \
|
||||
dnsutils \
|
||||
krb5-admin-server \
|
||||
krb5-config \
|
||||
krb5-kdc \
|
||||
krb5-user \
|
||||
libnss-winbind \
|
||||
libpam-krb5 \
|
||||
libpam-winbind \
|
||||
python3-samba \
|
||||
realmd \
|
||||
samba \
|
||||
samba-common-bin \
|
||||
samba-dsdb-modules \
|
||||
samba-vfs-modules \
|
||||
winbind \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@ -1,11 +0,0 @@
|
||||
package org.prole.authority;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class AuthorityApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AuthorityApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -1,13 +0,0 @@
|
||||
package org.prole.authority;
|
||||
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class HealthController {
|
||||
|
||||
@GetMapping("/health")
|
||||
public String health() {
|
||||
return "ok";
|
||||
}
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
server.port=8080
|
||||
|
||||
management.endpoints.web.exposure.include=health,info
|
||||
@ -1,12 +0,0 @@
|
||||
package org.prole.authority;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class AuthorityApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
@ -11,31 +11,66 @@
|
||||
</parent>
|
||||
|
||||
<groupId>org.prole</groupId>
|
||||
<artifactId>authority-parent</artifactId>
|
||||
<artifactId>authority</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>authority-parent</name>
|
||||
<description>Parent POM for the Knoe authority modules</description>
|
||||
<name>knoe-authority</name>
|
||||
<description>Knoe authentication gateway</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<modules>
|
||||
<module>common</module>
|
||||
<module>kdc</module>
|
||||
<module>prole-auth</module>
|
||||
<module>gcp-auth</module>
|
||||
</modules>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Kerberos/Security dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-kerberos-client</artifactId>
|
||||
<version>2.0.1</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.11.5</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.11.5</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<dependencyManagement>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.prole</groupId>
|
||||
<artifactId>authority-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</dependencyManagement>
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
|
||||
@ -1,11 +0,0 @@
|
||||
package org.prole.authority;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class AuthorityApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(AuthorityApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth;
|
||||
package org.prole.authority;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
@ -6,8 +6,8 @@ import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
@SpringBootApplication
|
||||
@ConfigurationPropertiesScan
|
||||
public class ProleAuthApplication {
|
||||
public class KnoeAuthApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ProleAuthApplication.class, args);
|
||||
SpringApplication.run(KnoeAuthApplication.class, args);
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.config;
|
||||
package org.prole.authority.config;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
@ -6,14 +6,14 @@ import java.util.List;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "prole.auth")
|
||||
@ConfigurationProperties(prefix = "knoe.auth")
|
||||
public class AuthProperties {
|
||||
private boolean enabled = true;
|
||||
private String cookieName = "prole_session";
|
||||
private String cookieDomain = ".prole.org";
|
||||
private String cookieName = "knoe_session";
|
||||
private String cookieDomain = ".knoe.dev";
|
||||
private Duration sessionTtl = Duration.ofHours(8);
|
||||
private String sessionSecret = "";
|
||||
private String emailDomain = "prole.org";
|
||||
private String emailDomain = "knoe.dev";
|
||||
private boolean formEnabled = false;
|
||||
private List<String> adminPrincipals = new ArrayList<>();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
package org.prole.auth.config;
|
||||
package org.prole.authority.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@ConfigurationProperties(prefix = "prole.kerberos")
|
||||
@ConfigurationProperties(prefix = "knoe.kerberos")
|
||||
public class KerberosProperties {
|
||||
private String servicePrincipal = "";
|
||||
private String keytabPath = "";
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.kerberos;
|
||||
package org.prole.authority.kerberos;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.kerberos;
|
||||
package org.prole.authority.kerberos;
|
||||
|
||||
import java.security.PrivilegedExceptionAction;
|
||||
import java.util.Map;
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.session;
|
||||
package org.prole.authority.session;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.session;
|
||||
package org.prole.authority.session;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.user;
|
||||
package org.prole.authority.user;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.web;
|
||||
package org.prole.authority.web;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
@ -10,13 +10,13 @@ import jakarta.annotation.PostConstruct;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.prole.auth.config.AuthProperties;
|
||||
import org.prole.auth.config.KerberosProperties;
|
||||
import org.prole.auth.kerberos.KerberosPasswordService;
|
||||
import org.prole.auth.kerberos.KerberosSpnegoService;
|
||||
import org.prole.auth.session.SessionTokenService;
|
||||
import org.prole.auth.session.SessionUser;
|
||||
import org.prole.auth.user.PrincipalNormalizer;
|
||||
import org.prole.authority.config.AuthProperties;
|
||||
import org.prole.authority.config.KerberosProperties;
|
||||
import org.prole.authority.kerberos.KerberosPasswordService;
|
||||
import org.prole.authority.kerberos.KerberosSpnegoService;
|
||||
import org.prole.authority.session.SessionTokenService;
|
||||
import org.prole.authority.session.SessionUser;
|
||||
import org.prole.authority.user.PrincipalNormalizer;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
@ -60,7 +60,7 @@ public class LoginController {
|
||||
return;
|
||||
}
|
||||
if (auth.getSessionSecret() == null || auth.getSessionSecret().isBlank()) {
|
||||
throw new IllegalStateException("prole.auth.sessionSecret is required when auth is enabled");
|
||||
throw new IllegalStateException("knoe.auth.sessionSecret is required when auth is enabled");
|
||||
}
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ public class LoginController {
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Prole Login</title>
|
||||
<title>Knoe Login</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 16px; }
|
||||
code { background: #f3f3f3; padding: 2px 4px; }
|
||||
@ -84,7 +84,7 @@ public class LoginController {
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Prole Login</h1>
|
||||
<h1>Knoe Login</h1>
|
||||
<div class="box">
|
||||
<h2>Kerberos (recommended)</h2>
|
||||
<p>If your browser is configured for Kerberos/SPNEGO, use this.</p>
|
||||
@ -238,7 +238,7 @@ public class LoginController {
|
||||
}
|
||||
|
||||
private URI safeNext(String next) {
|
||||
URI defaultNext = URI.create("https://svc.prole.org/");
|
||||
URI defaultNext = URI.create("https://svc.knoe.dev/");
|
||||
if (next == null || next.isBlank()) {
|
||||
return defaultNext;
|
||||
}
|
||||
@ -249,13 +249,13 @@ public class LoginController {
|
||||
return defaultNext;
|
||||
}
|
||||
String host = u.getHost();
|
||||
if (host == null || !host.endsWith(".prole.org")) {
|
||||
if (host == null || !host.endsWith(".knoe.dev")) {
|
||||
return defaultNext;
|
||||
}
|
||||
return u;
|
||||
}
|
||||
if (next.startsWith("/")) {
|
||||
return URI.create("https://svc.prole.org" + next);
|
||||
return URI.create("https://svc.knoe.dev" + next);
|
||||
}
|
||||
return defaultNext;
|
||||
} catch (Exception e) {
|
||||
@ -1,13 +1,13 @@
|
||||
package org.prole.auth.web;
|
||||
package org.prole.authority.web;
|
||||
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.Arrays;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.prole.auth.config.AuthProperties;
|
||||
import org.prole.auth.session.SessionTokenService;
|
||||
import org.prole.auth.session.SessionUser;
|
||||
import org.prole.authority.config.AuthProperties;
|
||||
import org.prole.authority.session.SessionTokenService;
|
||||
import org.prole.authority.session.SessionUser;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
11
authority/src/main/main.iml
Normal file
11
authority/src/main/main.iml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/java" isTestSource="false" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@ -1,3 +0,0 @@
|
||||
server.port=8080
|
||||
|
||||
management.endpoints.web.exposure.include=health,info
|
||||
@ -7,18 +7,18 @@ management:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
prole:
|
||||
knoe:
|
||||
auth:
|
||||
enabled: false
|
||||
cookieName: prole_session
|
||||
cookieDomain: ${PROLE_AUTH_COOKIE_DOMAIN:.prole.org}
|
||||
cookieName: knoe_session
|
||||
cookieDomain: ${KNOE_AUTH_COOKIE_DOMAIN:.knoe.dev}
|
||||
sessionTtl: 8h
|
||||
# REQUIRED in production when enabled. Provide via env: PROLE_AUTH_SESSION_SECRET
|
||||
# REQUIRED in production when enabled. Provide via env: KNOE_AUTH_SESSION_SECRET
|
||||
sessionSecret: ""
|
||||
emailDomain: prole.org
|
||||
emailDomain: knoe.dev
|
||||
formEnabled: false
|
||||
# Comma-separated list of bare usernames granted admin group membership.
|
||||
# Override via env: PROLE_AUTH_ADMIN_PRINCIPALS=admin
|
||||
# Override via env: KNOE_AUTH_ADMIN_PRINCIPALS=admin
|
||||
adminPrincipals: []
|
||||
kerberos:
|
||||
# REQUIRED for SPNEGO when enabled. Provide via env.
|
||||
@ -1,12 +0,0 @@
|
||||
package org.prole.authority;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest
|
||||
class AuthorityApplicationTests {
|
||||
|
||||
@Test
|
||||
void contextLoads() {
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package org.prole.auth.session;
|
||||
package org.prole.authority.session;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.Clock;
|
||||
@ -1,9 +1,9 @@
|
||||
package org.prole.auth.web;
|
||||
package org.prole.authority.web;
|
||||
|
||||
import java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.prole.auth.session.SessionTokenService;
|
||||
import org.prole.auth.session.SessionUser;
|
||||
import org.prole.authority.session.SessionTokenService;
|
||||
import org.prole.authority.session.SessionUser;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
11
authority/src/test/test.iml
Normal file
11
authority/src/test/test.iml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<sourceFolder url="file://$MODULE_DIR$/java" isTestSource="true" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
@ -14,7 +14,7 @@ The Prole Installer package includes several embedded resources that must be acc
|
||||
- **proleIconblueprint.png** (1.6 MB) - Blueprint icon variant
|
||||
|
||||
### 2. Binary Executables
|
||||
- **prole-net/prole-agent** (6.8 MB) - Network scanner
|
||||
- **scan/network-agent** (6.8 MB) - Network scanner
|
||||
- Universal binary (x86_64 + arm64)
|
||||
- Used by network scan screen
|
||||
- Detects Kerberos, Active Directory, etc.
|
||||
@ -54,7 +54,7 @@ if bg_path.exists():
|
||||
|
||||
**Binary Execution:**
|
||||
```python
|
||||
scan_binary = get_resource_path("prole-net/prole-agent")
|
||||
scan_binary = get_resource_path("scan/network-agent")
|
||||
if scan_binary.exists():
|
||||
process = subprocess.Popen([str(scan_binary)], ...)
|
||||
```
|
||||
@ -86,7 +86,7 @@ datas = [
|
||||
**Binaries:**
|
||||
```python
|
||||
binaries = [
|
||||
('prole-net/prole-agent', 'prole-net'),
|
||||
('scan/network-agent', 'scan'),
|
||||
]
|
||||
```
|
||||
|
||||
@ -98,7 +98,7 @@ Total embedded resources: ~30-35 MB
|
||||
|
||||
Breakdown:
|
||||
- Images: ~11 MB
|
||||
- prole-agent: 6.8 MB
|
||||
- network-agent: 6.8 MB
|
||||
- Prole Tools.app: ~12 MB
|
||||
- Other resources: ~5-10 MB
|
||||
|
||||
@ -108,10 +108,10 @@ Final installer bundle: ~50-100 MB (includes Python runtime)
|
||||
|
||||
### Network Scan Screen
|
||||
|
||||
The network scan screen uses `prole-agent` to detect services:
|
||||
The network scan screen uses `network-agent` to detect services:
|
||||
|
||||
```python
|
||||
scan_binary = get_resource_path("prole-net/prole-agent")
|
||||
scan_binary = get_resource_path("scan/network-agent")
|
||||
process = subprocess.Popen([str(scan_binary)],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
@ -140,7 +140,7 @@ python3 test_embedded_resources.sh
|
||||
|
||||
This tests:
|
||||
- ✓ All image files exist
|
||||
- ✓ prole-agent binary exists and is executable
|
||||
- ✓ network-agent binary exists and is executable
|
||||
- ✓ Prole Tools.app bundle exists
|
||||
- ✓ Spec file includes all resources
|
||||
- ✓ Resource sizes
|
||||
@ -154,7 +154,7 @@ After building:
|
||||
./dist/prole-installer --help
|
||||
|
||||
# In another terminal, while installer is running:
|
||||
ls -la /tmp/_MEI*/prole-net/
|
||||
ls -la /tmp/_MEI*/scan/
|
||||
ls -la /tmp/_MEI*/img/
|
||||
ls -la "/tmp/_MEI*/prole-app/dist/Prole Tools.app"
|
||||
```
|
||||
@ -163,18 +163,18 @@ ls -la "/tmp/_MEI*/prole-app/dist/Prole Tools.app"
|
||||
|
||||
### Binary Not Found Error
|
||||
|
||||
**Error:** `Scan binary not found at /var/folders/.../prole-net/prole-agent`
|
||||
**Error:** `Scan binary not found at /var/folders/.../scan/network-agent`
|
||||
|
||||
**Cause:** Binary not included in package or path not using `get_resource_path()`
|
||||
|
||||
**Solution:**
|
||||
1. Verify spec includes binary: `grep prole-agent installer.spec`
|
||||
2. Check code uses `get_resource_path()`: `grep "get_resource_path.*prole-agent" install.py`
|
||||
1. Verify spec includes binary: `grep network-agent installer.spec`
|
||||
2. Check code uses `get_resource_path()`: `grep "get_resource_path.*network-agent" install.py`
|
||||
3. Rebuild: `make clean && make package`
|
||||
|
||||
### Binary Not Executable
|
||||
|
||||
**Error:** `Permission denied` when running prole-agent
|
||||
**Error:** `Permission denied` when running network-agent
|
||||
|
||||
**Cause:** Binary permissions not preserved in package
|
||||
|
||||
@ -182,10 +182,10 @@ ls -la "/tmp/_MEI*/prole-app/dist/Prole Tools.app"
|
||||
Ensure binary is in `binaries` list (not `datas`) in spec file:
|
||||
```python
|
||||
binaries = [
|
||||
('prole-net/prole-agent', 'prole-net'), # Correct - preserves +x
|
||||
('scan/network-agent', 'scan'), # Correct - preserves +x
|
||||
]
|
||||
# NOT in datas:
|
||||
# datas = [('prole-net/prole-agent', 'prole-net')] # Wrong - loses +x
|
||||
# datas = [('scan/network-agent', 'scan')] # Wrong - loses +x
|
||||
```
|
||||
|
||||
### App Bundle Not Found
|
||||
@ -217,7 +217,7 @@ binaries = [
|
||||
3. **Exclude debug symbols:**
|
||||
Strip binaries before packaging:
|
||||
```bash
|
||||
strip prole-net/prole-agent
|
||||
strip scan/network-agent
|
||||
```
|
||||
|
||||
## Build Process
|
||||
@ -250,7 +250,7 @@ When the packaged installer runs:
|
||||
Embedded binaries should be code signed:
|
||||
|
||||
```bash
|
||||
codesign --force --sign "Developer ID Application: Your Name" prole-net/prole-agent
|
||||
codesign --force --sign "Developer ID Application: Your Name" scan/network-agent
|
||||
```
|
||||
|
||||
Then build the installer - the signed binary will be included.
|
||||
@ -260,8 +260,8 @@ Then build the installer - the signed binary will be included.
|
||||
Users can verify embedded binaries:
|
||||
|
||||
```bash
|
||||
# Check signature of prole-agent after extraction
|
||||
codesign --verify --verbose /tmp/_MEI*/prole-net/prole-agent
|
||||
# Check signature of network-agent after extraction
|
||||
codesign --verify --verbose /tmp/_MEI*/scan/network-agent
|
||||
|
||||
# Check installer bundle signature
|
||||
codesign --verify --verbose "dist/Prole Installer.app"
|
||||
|
||||
@ -97,7 +97,7 @@ A Python script (`scripts/generate_spec.py`) creates the PyInstaller specificati
|
||||
- `prole-app/dist/Prole Tools.app` - Pre-built Prole Tools application bundle (entire .app)
|
||||
|
||||
**Included Binaries:**
|
||||
- `prole-net/prole-agent` - Network scanner binary (universal: x86_64 + arm64, 6.8 MB)
|
||||
- `scan/network-agent` - Network scanner binary (universal: x86_64 + arm64, 6.8 MB)
|
||||
|
||||
**Resource Path Resolution:**
|
||||
The installer uses a `get_resource_path()` helper function that automatically resolves paths correctly whether running from source or from a PyInstaller bundle:
|
||||
|
||||
14
install.sh
14
install.sh
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# Thin launch vehicle for the Prole installer.
|
||||
# Thin launch vehicle for the Knoe installer.
|
||||
#
|
||||
# Local execution (from a repo checkout):
|
||||
# ./install.sh [args...]
|
||||
@ -27,9 +27,9 @@
|
||||
set -euo pipefail
|
||||
|
||||
# Repo URL used only during bootstrap (curl-pipe) installs.
|
||||
PROLE_REPO_URL="${PROLE_REPO_URL:-https://gitlab.knoey.com/prole/prole.git}"
|
||||
KNOE_REPO_URL="${KNOE_REPO_URL:-https://gitlab.knoey.com/prole/prole.git}"
|
||||
# Default install directory for bootstrap installs.
|
||||
_INSTALL_DIR="${PROLE_HOME:-$HOME/prole}"
|
||||
_INSTALL_DIR="${KNOE_HOME:-$HOME/knoe}"
|
||||
|
||||
_check_gcp_tools() {
|
||||
local missing=0
|
||||
@ -51,7 +51,7 @@ _check_gcp_tools() {
|
||||
}
|
||||
|
||||
_bootstrap() {
|
||||
echo "==> Bootstrapping Prole installer..."
|
||||
echo "==> Bootstrapping Knoe installer..."
|
||||
command -v git >/dev/null 2>&1 || { echo "Error: git is required." >&2; exit 1; }
|
||||
command -v python3 >/dev/null 2>&1 || { echo "Error: python3 is required." >&2; exit 1; }
|
||||
|
||||
@ -60,7 +60,7 @@ _bootstrap() {
|
||||
git -C "${_INSTALL_DIR}" pull --ff-only
|
||||
else
|
||||
echo "==> Cloning to ${_INSTALL_DIR} ..."
|
||||
git clone "${PROLE_REPO_URL}" "${_INSTALL_DIR}"
|
||||
git clone "${KNOE_REPO_URL}" "${_INSTALL_DIR}"
|
||||
fi
|
||||
|
||||
exec "${_INSTALL_DIR}/install.sh" "$@"
|
||||
@ -73,9 +73,9 @@ _script_dir="$(cd "$(dirname "${_src:-/}")" 2>/dev/null && pwd || echo "")"
|
||||
if [[ -n "${_script_dir}" && -d "${_script_dir}/knoe" ]]; then
|
||||
# Local checkout: delegate to the Python installer module.
|
||||
cd "${_script_dir}"
|
||||
# Prefer the venv Python at PROLE_HOME (or the repo root) when available;
|
||||
# Prefer the venv Python at KNOE_HOME (or the repo root) when available;
|
||||
# it carries all prole_requirements.txt dependencies.
|
||||
_VENV_PYTHON="${PROLE_HOME:-${_script_dir}}/bin/python3"
|
||||
_VENV_PYTHON="${KNOE_HOME:-${_script_dir}}/bin/python3"
|
||||
_check_gcp_tools
|
||||
if [[ -x "${_VENV_PYTHON}" ]]; then
|
||||
exec "${_VENV_PYTHON}" -m knoe.ui.screens "$@"
|
||||
|
||||
68
knoe-db.iml
68
knoe-db.iml
@ -2,75 +2,7 @@
|
||||
<module version="4">
|
||||
<component name="AdditionalModuleElements">
|
||||
<content url="file://$MODULE_DIR$" dumb="true">
|
||||
<sourceFolder url="file://$MODULE_DIR$" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/authority" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/authority/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/authority/src/main" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/authority/src/test" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/conf" type="java-resource" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/docs" type="java-resource" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/etc" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/gitea" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/infrastructure" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/k3s" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/k8s" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/knoe" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/knoe-db" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/modes" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/net" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prod" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole-app" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole-auth" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole-auth/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole-mssql-db" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole-net" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/prole-tools-app" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/scan" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/scripts" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/secrets" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/service" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/sql" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/supabase" isTestSource="false" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
|
||||
<sourceFolder url="file://$MODULE_DIR$/tools" isTestSource="false" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/deploy/gcp/terraform/.terraform" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/deploy/opentofu/k3s/.terraform" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.ansible" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.idea" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.vagrant" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.vscode" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/<MagicMock name='mock.Entry().get().strip().strip()' id='4677834960'>" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/bin" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/build" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/data" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/deploy" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/dev" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/dist" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/etc/secrets" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/htmlcov" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/img" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/include" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/knoe-db/data" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/lib" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/logs" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/mock_val" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/prole-tools-app" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/ssh-keys" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/tmp" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/vault_backup" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/.claude" />
|
||||
</content>
|
||||
</component>
|
||||
<component name="FacetManager">
|
||||
<facet type="Spring" name="Spring">
|
||||
<configuration />
|
||||
</facet>
|
||||
<facet type="Python" name="Python">
|
||||
<configuration sdkName="Python 3.14 (prole)" />
|
||||
</facet>
|
||||
</component>
|
||||
</module>
|
||||
@ -22,7 +22,7 @@ datas = [
|
||||
|
||||
# Binaries to include (with execute permissions)
|
||||
binaries = [
|
||||
('prole-net/prole-agent', 'prole-net'),
|
||||
('scan/network-agent', 'scan'),
|
||||
]
|
||||
|
||||
# Hidden imports
|
||||
|
||||
@ -5031,7 +5031,7 @@ class KnoeConsoleInstaller(KnoeInstaller):
|
||||
)
|
||||
return
|
||||
|
||||
self.log("==> Network scan (prole-agent)")
|
||||
self.log("==> Network scan (network-agent)")
|
||||
ansible_kdc = ""
|
||||
try:
|
||||
ansible_kdc = (self.prole_cfg_data.get("Network", {}) or {}).get(
|
||||
@ -5039,7 +5039,7 @@ class KnoeConsoleInstaller(KnoeInstaller):
|
||||
)
|
||||
except Exception:
|
||||
ansible_kdc = ""
|
||||
scan_binary = get_resource_path("prole-net/prole-agent")
|
||||
scan_binary = get_resource_path("scan/network-agent")
|
||||
if not scan_binary.exists():
|
||||
self.err(f"[ERROR] Scan binary not found at {scan_binary}")
|
||||
return
|
||||
|
||||
@ -524,7 +524,7 @@ class NetworkScanMilestone(Milestone):
|
||||
self.logger.info(f"Network scan already performed. KDC: {existing_kdc}")
|
||||
return
|
||||
|
||||
scan_binary = inst_config.get_resource_path("prole-net/prole-agent")
|
||||
scan_binary = inst_config.get_resource_path("scan/network-agent")
|
||||
if not scan_binary.exists():
|
||||
self.logger.error(f"Scan binary not found at {scan_binary}")
|
||||
return
|
||||
|
||||
@ -603,7 +603,7 @@ class KnoeNcursesInstaller:
|
||||
threading.Thread(target=_worker, daemon=True).start()
|
||||
|
||||
def _run_network_scan(self):
|
||||
"""Run network scan via prole-agent."""
|
||||
"""Run network scan via network-agent."""
|
||||
if getattr(self, "_scan_running", False):
|
||||
return
|
||||
self._scan_running = True
|
||||
@ -612,7 +612,7 @@ class KnoeNcursesInstaller:
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
scan_binary = inst_config.get_resource_path("prole-net/prole-agent")
|
||||
scan_binary = inst_config.get_resource_path("scan/network-agent")
|
||||
if not scan_binary.exists():
|
||||
self.scan_results_console.write(
|
||||
f"Error: scan binary not found at {scan_binary}\n"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"""Network scan screen.
|
||||
|
||||
This screen combines two discovery activities into a single operator workflow:
|
||||
- Kerberos authority / KDC candidate detection (via `prole-net/prole-agent`)
|
||||
- Kerberos authority / KDC candidate detection (via `scan/network-agent`)
|
||||
- Ollama server discovery (via `etc/init_ollama.sh scan`)
|
||||
"""
|
||||
|
||||
@ -369,7 +369,7 @@ class NetworkScreenMixin:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self._combined_console_line("network", "Initializing prole-net/prole-agent ...")
|
||||
self._combined_console_line("network", "Initializing scan/network-agent ...")
|
||||
self._combined_console_line("ollama", "Initializing subnet scan ...")
|
||||
|
||||
# Track completion of both scan activities
|
||||
@ -402,7 +402,7 @@ class NetworkScreenMixin:
|
||||
|
||||
self.safe_after(_finish)
|
||||
|
||||
# --- Thread 1: prole-agent scan (network) ---
|
||||
# --- Thread 1: network-agent scan (network) ---
|
||||
def network_worker():
|
||||
ansible_kdc = ""
|
||||
try:
|
||||
@ -415,7 +415,7 @@ class NetworkScreenMixin:
|
||||
ansible_kdc = ""
|
||||
|
||||
try:
|
||||
scan_binary = get_resource_path("prole-net/prole-agent")
|
||||
scan_binary = get_resource_path("scan/network-agent")
|
||||
if not scan_binary.exists():
|
||||
self._combined_console_line(
|
||||
"network", f"Scan binary not found at {scan_binary}"
|
||||
@ -490,7 +490,7 @@ class NetworkScreenMixin:
|
||||
m = kdc_re.search(line)
|
||||
if m:
|
||||
host = (m.group("host") or "").strip()
|
||||
_maybe_add_kdc_candidate(host, "prole-agent")
|
||||
_maybe_add_kdc_candidate(host, "network-agent")
|
||||
_maybe_set_kdc_primary(host)
|
||||
continue
|
||||
# Fallback heuristic: AD / port 88 lines containing IPs
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
{
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "cryptoswift",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/royalapplications/CryptoSwift.git",
|
||||
"state" : {
|
||||
"branch" : "foundationessentials",
|
||||
"revision" : "a59b4d91ebb22011656c830f874fe7152e183a57"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "royalvnc",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/royalapplications/royalvnc.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "50ee7732796b0059d9b9aa244d213540340ae645"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-atomics",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-atomics.git",
|
||||
"state" : {
|
||||
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-collections",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-collections.git",
|
||||
"state" : {
|
||||
"revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e",
|
||||
"version" : "1.3.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio.git",
|
||||
"state" : {
|
||||
"revision" : "663ddc80f2081c8f22e417cbac5f80270a93795e",
|
||||
"version" : "2.91.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-irc",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/SwiftNIOExtras/swift-nio-irc.git",
|
||||
"state" : {
|
||||
"revision" : "782e6d3bcd892efaedc9c530b2d5c5fbb42a680e",
|
||||
"version" : "0.8.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-irc-client",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/NozeIO/swift-nio-irc-client.git",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "7a0b7d4cde33614b7b0af193cd8ff0f7f305eb77"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-nio-transport-services",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-nio-transport-services.git",
|
||||
"state" : {
|
||||
"revision" : "60c3e187154421171721c1a38e800b390680fb5d",
|
||||
"version" : "1.26.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-system",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-system.git",
|
||||
"state" : {
|
||||
"revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db",
|
||||
"version" : "1.6.3"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 2
|
||||
}
|
||||
@ -1,33 +0,0 @@
|
||||
// swift-tools-version: 5.9
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "ProleApp",
|
||||
platforms: [
|
||||
.macOS(.v12)
|
||||
],
|
||||
products: [
|
||||
.executable(name: "Prole", targets: ["Prole"]) // App binary name
|
||||
],
|
||||
dependencies: [
|
||||
// NozeIO SwiftNIO IRC Client
|
||||
.package(url: "https://github.com/NozeIO/swift-nio-irc-client.git", branch: "main")
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "Prole",
|
||||
dependencies: [
|
||||
.product(name: "IRC", package: "swift-nio-irc-client")
|
||||
],
|
||||
path: "Sources",
|
||||
resources: [
|
||||
// The build script separately copies resources into the app bundle; no SPM resources here.
|
||||
],
|
||||
linkerSettings: [
|
||||
.linkedFramework("AppKit"),
|
||||
.linkedFramework("Carbon"),
|
||||
.linkedFramework("Network")
|
||||
]
|
||||
)
|
||||
]
|
||||
)
|
||||
@ -1,118 +0,0 @@
|
||||
```
|
||||
#####################################################
|
||||
# ╭───────────────────────────────────────────────╮ #
|
||||
# │ _ ___ _ _ │ #
|
||||
# │ _ __ _ _ ___| |___/ __| |_ __ _| |_ _ _ ___ │ #
|
||||
# │ | '_ \ '_/ _ \ / -_)__ \ _/ _` | _| || (_-< │ #
|
||||
# │ | .__/_| \___/_\___|___/\__\__,_|\__|\_,_/__/ │ #
|
||||
# │ |_| │ #
|
||||
# ╰───────────────────────────────────────────────╯ #
|
||||
#####################################################
|
||||
```
|
||||
|
||||
Prole
|
||||
— macOS status and control surface for Prole endpoints.
|
||||
|
||||
Overview
|
||||
- Prole provides live reachability and latency signals for Prole service endpoints. It operates in two modes: a regular application window for situational awareness and a minimalist status‑bar overlay for persistent at‑a‑glance status.
|
||||
|
||||
Supported platform
|
||||
- macOS 12.0+ (Monterey or newer) on Apple Silicon (arm64) and Intel (x86_64). Universal builds are supported by the build script.
|
||||
|
||||
Execution environment requirements (runtime)
|
||||
- No external daemons or brew packages are required to run the built app bundle.
|
||||
- Network access to the configured endpoints.
|
||||
|
||||
Build environment requirements
|
||||
- Xcode Command Line Tools (swiftc, xcrun). Install if needed:
|
||||
```
|
||||
xcode-select --install
|
||||
```
|
||||
- System tools used by the build:
|
||||
- `iconutil` and `sips` (for `.icns` generation)
|
||||
- `codesign` (ad‑hoc signing)
|
||||
- `plutil` (plist formatting, via xcrun if needed)
|
||||
|
||||
Repository layout (subset)
|
||||
- `prole-app/` — macOS app sources and build system
|
||||
- `Sources/` — Swift sources (AppKit)
|
||||
- `build.sh` — hermetic CLI build producing a `.app` bundle
|
||||
- `prole.properties` — default endpoint configuration (bundled into Resources)
|
||||
- `dist/Prole.app` — build output
|
||||
|
||||
Build script
|
||||
- The build is driven by `prole-app/build.sh`. Typical usage:
|
||||
```
|
||||
cd prole-app
|
||||
./build.sh build # build for host arch
|
||||
./build.sh run # build (if needed) and open the app
|
||||
./build.sh build-universal # produce a universal (arm64+x86_64) binary
|
||||
./build.sh debug # run in foreground with verbose logs
|
||||
./build.sh clean # remove build artifacts
|
||||
./build.sh package # zip dist/Prole.app into dist/Prole.zip
|
||||
```
|
||||
|
||||
What the script does
|
||||
- Compiles all Swift sources with `swiftc` (AppKit, Carbon, Network frameworks).
|
||||
- Generates `Contents/Info.plist` with `LSUIElement=false` so the app can present a standard menu when in Application Window mode.
|
||||
- Generates an application icon (`Prole.icns`) and a template status glyph as needed.
|
||||
- Copies resources:
|
||||
- `www/images/prole-type.gif` → `Contents/Resources/prole-type.gif` (for the startup tip splash)
|
||||
- `prole-app/prole.properties` → `Contents/Resources/prole.properties`
|
||||
- Performs ad‑hoc code signing of the `.app` bundle.
|
||||
|
||||
Alternate build path (installer UI)
|
||||
- The repository includes `install.py`, a Tkinter helper that can orchestrate the build. From the repository root:
|
||||
```
|
||||
python3 install.py
|
||||
```
|
||||
- Use the “Build Prole macOS app” step. The installer will produce `prole-app/dist/Prole.app` and can optionally copy it to `/Applications`.
|
||||
|
||||
Run modes and controls
|
||||
- Modes:
|
||||
- Application Window mode (default): resizable window with three vertical status rows (svc, k3s aggregate, local) and a timestamp in the top‑right. A small control bar bottom‑left exposes Refresh (⟳) and Minimize to Status Bar (_).
|
||||
- Status Bar mode: thin overlay aligned with the macOS menu bar; shows a scrolling summary and a 'maximize' button (□) to return to the main window.
|
||||
- Toggle between modes with the global shortcut:
|
||||
- `Cmd`+`Option`+`Shift`+`P`
|
||||
- Menus:
|
||||
- Application menu “Prole” (next to the Apple menu): Show Status Bar / Show Main Window (same toggle as the hotkey), Refresh Now, Quit.
|
||||
- Status‑bar “P” icon (right‑click): Show/Hide Status Bar Icon, Show/Hide Application Window, Refresh Now, Quit.
|
||||
|
||||
Configuration
|
||||
- Endpoint configuration is provided via Java‑style `key=value` properties. Two locations are read at startup; the user override has precedence:
|
||||
1. Bundled defaults: `Prole.app/Contents/Resources/prole.properties`
|
||||
2. User override (optional): `~/Library/Application Support/Prole/prole.properties`
|
||||
- Default keys:
|
||||
- `svc.host`, `svc.port`
|
||||
- `k3s.retropie.host`, `k3s.retropie.port`
|
||||
- `k3s.pi.host`, `k3s.pi.port`
|
||||
- `k3d.local.host`, `k3d.local.port`
|
||||
- Example user override:
|
||||
```
|
||||
# Override core service endpoint
|
||||
svc.host=svc.my-domain.tld
|
||||
svc.port=443
|
||||
|
||||
# Local k3d on a custom port
|
||||
k3d.local.port=6445
|
||||
```
|
||||
|
||||
Operational notes
|
||||
- Status checks are TCP connect probes executed on a background timer (default: 30s). Latency is the connection time in milliseconds; failures record a short diagnostic for tooltips.
|
||||
- The splash screen is transient (~ 5s) and can be dismissed with a click. It loads `prole-type.gif` if present in Resources.
|
||||
|
||||
Diagnostics & troubleshooting
|
||||
- Ensure Xcode CLT is installed if the build fails:
|
||||
```
|
||||
xcode-select --install
|
||||
```
|
||||
- If the app launches without a standard menu/window, verify `Info.plist` has `LSUIElement=false` (the build script sets this). Rebuild using `build.sh`.
|
||||
- If the hotkey appears inactive, bring the app to the foreground or use the Prole menu item (it triggers the same toggle).
|
||||
- If status indicators stay red, validate network reachability and adjust `prole.properties` to endpoints reachable from your host.
|
||||
- For ad‑hoc logging, search the sources for `dlog("…")` and run via `./build.sh debug` to watch stdout.
|
||||
|
||||
Security & signing
|
||||
- The `.app` bundle is ad‑hoc signed by default. For distribution, replace with a Developer ID signature and notarize as appropriate for your environment.
|
||||
|
||||
License
|
||||
- See the repository `LICENSE` file.
|
||||
@ -1,309 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// AppDelegate is the app "traffic controller".
|
||||
// It wires up:
|
||||
// - the status bar overlay window (Status Bar mode)
|
||||
// - the regular main window (Application Window mode)
|
||||
// - the global hotkey Cmd+Opt+Shift+P to toggle modes
|
||||
// - the top application menu (Prole → Show/Hide, Refresh, Quit)
|
||||
//
|
||||
// Tip for learners:
|
||||
// In macOS apps, NSApplication + NSApp.run() starts the event loop.
|
||||
// AppDelegate receives lifecycle callbacks like applicationDidFinishLaunching.
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
private var overlayWindowController: OverlayWindowController!
|
||||
private var mainWindowController: MainWindowController!
|
||||
private var dbStatusWindowController: DBStatusWindowController!
|
||||
private var ircWindowController: IRCWindowController!
|
||||
private var hotKeyManager: HotKeyManager!
|
||||
private var serviceChecker: ServiceChecker!
|
||||
private var statusItemController: StatusItemController!
|
||||
// Splash screen removed — keep no reference
|
||||
private var appMenuToggleStatusBarItem: NSMenuItem?
|
||||
// Port-forwarding is now managed by a user LaunchAgent. Keep a small helper.
|
||||
private let launchAgentManager = LaunchAgentManager()
|
||||
private var preferencesWindowController: PreferencesWindowController?
|
||||
|
||||
// We keep the app in one of two simple modes.
|
||||
// - statusBar: shows the thin overlay near the macOS menu bar
|
||||
// - appWindow: shows the regular resizable window
|
||||
private enum Mode { case statusBar, appWindow }
|
||||
private var mode: Mode = .appWindow
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
dlog("applicationDidFinishLaunching")
|
||||
// Bootstrap PROLE_HOME directories and config/log files
|
||||
ProleEnv.bootstrap()
|
||||
Logger.shared.info("Prole Tools starting up")
|
||||
// We'll start in Application Window mode and use regular activation policy
|
||||
// .regular = normal app with menu bar and windows
|
||||
// .accessory = utility app without a Dock icon/menu bar (good for status‑bar utilities)
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
dlog("Activation policy set to .regular (starting in App Window mode)")
|
||||
|
||||
// Splash removed: start directly
|
||||
|
||||
// No more helper script bundling. All shell commands go via $PROLE_HOME/env.sh.
|
||||
|
||||
// ServiceChecker does the lightweight TCP checks on a background timer
|
||||
serviceChecker = ServiceChecker()
|
||||
dlog("ServiceChecker created")
|
||||
// Note: In-app port-forward supervision has been removed.
|
||||
// Port-forwards are handled by LaunchAgents. Nothing to start here.
|
||||
|
||||
// Overlay (Status Bar mode) — a thin, mostly click‑through window near the top
|
||||
overlayWindowController = OverlayWindowController(serviceChecker: serviceChecker, pfManager: nil)
|
||||
dlog("OverlayWindowController created; computing initial frame/visibility")
|
||||
overlayWindowController.showIfMenuBarVisible()
|
||||
|
||||
// Main window (Application Window mode) — vertical, simple status view
|
||||
mainWindowController = MainWindowController(serviceChecker: serviceChecker, actions: .init(
|
||||
onRefresh: { [weak self] in self?.refreshNow() },
|
||||
onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() }
|
||||
), pfManager: nil)
|
||||
dlog("MainWindowController created")
|
||||
|
||||
// Database status window — positioned slightly down and left of main window
|
||||
dbStatusWindowController = DBStatusWindowController(actions: .init(
|
||||
onMinimizeToStatusBar: { [weak self] in self?.switchToStatusBarMode() }
|
||||
))
|
||||
dlog("DBStatusWindowController created")
|
||||
|
||||
// IRC window — short vertically, long horizontally, positioned below Prole Status
|
||||
ircWindowController = IRCWindowController()
|
||||
dlog("IRCWindowController created")
|
||||
|
||||
// Create status bar item with icon and click handler
|
||||
// Status bar item with a monospaced "P" and its own right‑click menu
|
||||
statusItemController = StatusItemController(actions: .init(
|
||||
onLeftClick: { [weak self] in
|
||||
guard let self = self else { return }
|
||||
switch self.mode {
|
||||
case .statusBar: self.overlayWindowController.showOverlayNow()
|
||||
case .appWindow: self.mainWindowController.show()
|
||||
}
|
||||
},
|
||||
onToggleStatusBar: { [weak self] in self?.toggleStatusBarVisibility() },
|
||||
onToggleAppWindow: { [weak self] in self?.toggleAppWindowVisibility() },
|
||||
onRefresh: { [weak self] in self?.refreshNow() },
|
||||
onQuit: { NSApp.terminate(nil) }
|
||||
))
|
||||
dlog("Status bar item created")
|
||||
|
||||
// Register the global hotkey: Cmd+Opt+Shift+P → toggle modes
|
||||
hotKeyManager = HotKeyManager()
|
||||
hotKeyManager.registerGlobalHotKey(modifiers: [.command, .option, .shift], key: .p) { [weak self] in
|
||||
self?.toggleMode()
|
||||
}
|
||||
dlog("Global hotkey registered (Cmd+Opt+Shift+P) — toggle modes")
|
||||
|
||||
// Observe screen/space/menu bar visibility changes
|
||||
// (So the overlay can reposition itself correctly.)
|
||||
DistributedNotificationCenter.default().addObserver(self, selector: #selector(handleConfigChange), name: NSNotification.Name("com.apple.HIToolbox.inputSourceChanged"), object: nil)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(handleConfigChange), name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||
NSWorkspace.shared.notificationCenter.addObserver(self, selector: #selector(spaceChanged), name: NSWorkspace.activeSpaceDidChangeNotification, object: nil)
|
||||
// Observe request to show main window from overlay maximize button
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(showMainWindowRequested), name: .showMainWindow, object: nil)
|
||||
// Observe toggle mode request (simulate Cmd+Opt+Shift+P)
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(menuToggleMode), name: .toggleMode, object: nil)
|
||||
|
||||
// Check status every 30 seconds
|
||||
// (Learner tweak: change this interval to refresh more/less often.)
|
||||
dlog("Starting ServiceChecker timer (interval: 30s)")
|
||||
serviceChecker.start(interval: 30)
|
||||
|
||||
// No splash controller to release
|
||||
|
||||
// Build application menu (shown when activation policy is .regular)
|
||||
setupApplicationMenu()
|
||||
|
||||
// Observe config changes: refresh status on save
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(menuRefresh), name: Config.didChangeNotification, object: nil)
|
||||
|
||||
// Start in Application Window mode by default
|
||||
// Ensure main window is positioned at the left and visible immediately
|
||||
if let mainWin = mainWindowController.window { MainWindowController.positionWindowAtLeftEdge(mainWin) }
|
||||
switchToAppWindowMode()
|
||||
// Ensure menu reflects current visibility
|
||||
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: mainWindowController.isVisible)
|
||||
updateApplicationMenuTitles()
|
||||
}
|
||||
|
||||
@objc private func handleConfigChange() {
|
||||
dlog("handleConfigChange → recomputeFrameAndVisibility")
|
||||
if mode == .statusBar { overlayWindowController.recomputeFrameAndVisibility() }
|
||||
}
|
||||
|
||||
@objc private func spaceChanged() {
|
||||
dlog("spaceChanged → recomputeFrameAndVisibility")
|
||||
if mode == .statusBar { overlayWindowController.recomputeFrameAndVisibility() }
|
||||
}
|
||||
|
||||
// MARK: - Mode and Actions
|
||||
private func toggleMode() {
|
||||
// Simple 2‑state switch
|
||||
switch mode {
|
||||
case .statusBar:
|
||||
switchToAppWindowMode()
|
||||
case .appWindow:
|
||||
switchToStatusBarMode()
|
||||
}
|
||||
}
|
||||
|
||||
private func switchToStatusBarMode() {
|
||||
mode = .statusBar
|
||||
mainWindowController.hide()
|
||||
dbStatusWindowController.hide()
|
||||
ircWindowController.hide()
|
||||
overlayWindowController.showOverlayNow()
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
// Ensure the status bar icon is visible while in minimized scrolling status bar mode
|
||||
statusItemController.showStatusItem()
|
||||
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: false)
|
||||
dlog("Switched to Status Bar mode")
|
||||
updateApplicationMenuTitles()
|
||||
}
|
||||
|
||||
private func switchToAppWindowMode() {
|
||||
mode = .appWindow
|
||||
overlayWindowController.hideOverlay()
|
||||
mainWindowController.show()
|
||||
// Position database status window slightly down and to the left of the main window
|
||||
if let ref = mainWindowController.window { dbStatusWindowController.position(relativeTo: ref) }
|
||||
dbStatusWindowController.show()
|
||||
// Position IRC window below the main window and show it
|
||||
if let ref = mainWindowController.window { ircWindowController.position(below: ref) }
|
||||
ircWindowController.show()
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
// Hide the status bar icon while the main window UI is active (optional per requirements)
|
||||
statusItemController.hideStatusItem()
|
||||
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: true)
|
||||
dlog("Switched to Application Window mode")
|
||||
updateApplicationMenuTitles()
|
||||
}
|
||||
|
||||
private func toggleStatusBarVisibility() {
|
||||
if statusItemController.isVisible {
|
||||
statusItemController.hideStatusItem()
|
||||
} else {
|
||||
statusItemController.showStatusItem()
|
||||
}
|
||||
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: (mode == .appWindow && mainWindowController.isVisible))
|
||||
updateApplicationMenuTitles()
|
||||
}
|
||||
|
||||
private func toggleAppWindowVisibility() {
|
||||
if mainWindowController.isVisible {
|
||||
mainWindowController.hide()
|
||||
dbStatusWindowController.hide()
|
||||
ircWindowController.hide()
|
||||
if mode == .appWindow { mode = .statusBar }
|
||||
} else {
|
||||
mainWindowController.show()
|
||||
if let ref = mainWindowController.window {
|
||||
dbStatusWindowController.position(relativeTo: ref)
|
||||
ircWindowController.position(below: ref)
|
||||
}
|
||||
dbStatusWindowController.show()
|
||||
ircWindowController.show()
|
||||
mode = .appWindow
|
||||
overlayWindowController.hideOverlay()
|
||||
}
|
||||
statusItemController.setState(statusBarVisible: statusItemController.isVisible, appWindowVisible: mainWindowController.isVisible)
|
||||
}
|
||||
|
||||
private func refreshNow() {
|
||||
// Broadcast a notification; ServiceChecker listens and forces a check.
|
||||
NotificationCenter.default.post(name: ServiceChecker.forceRefreshNotification, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Application Menu
|
||||
private func setupApplicationMenu() {
|
||||
// Build a minimal menu programmatically to keep the project small
|
||||
let mainMenu = NSMenu(title: "MainMenu")
|
||||
let appMenuItem = NSMenuItem()
|
||||
mainMenu.addItem(appMenuItem)
|
||||
|
||||
let appMenu = NSMenu(title: "Prole Tools")
|
||||
// Toggle between Application Window and Status Bar modes (same as Cmd+Opt+Shift+P)
|
||||
// Title updates dynamically via updateApplicationMenuTitles()
|
||||
let toggle = NSMenuItem(title: "Show Status Bar", action: #selector(menuToggleMode), keyEquivalent: "p")
|
||||
toggle.keyEquivalentModifierMask = [.command, .option, .shift]
|
||||
toggle.target = self
|
||||
appMenu.addItem(toggle)
|
||||
self.appMenuToggleStatusBarItem = toggle
|
||||
|
||||
// Preferences…
|
||||
let prefs = NSMenuItem(title: "Preferences…", action: #selector(menuPreferences), keyEquivalent: ",")
|
||||
prefs.keyEquivalentModifierMask = [.command]
|
||||
prefs.target = self
|
||||
appMenu.addItem(prefs)
|
||||
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Restart Port Forwards (delegates to etc/init-port-fowards.sh)
|
||||
let resetPF = NSMenuItem(title: "Restart Port Forwards", action: #selector(menuResetPortForwards), keyEquivalent: "")
|
||||
resetPF.target = self
|
||||
appMenu.addItem(resetPF)
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Refresh
|
||||
let refresh = NSMenuItem(title: "Refresh Now", action: #selector(menuRefresh), keyEquivalent: "r")
|
||||
refresh.target = self
|
||||
appMenu.addItem(refresh)
|
||||
|
||||
appMenu.addItem(NSMenuItem.separator())
|
||||
|
||||
// Quit
|
||||
let quit = NSMenuItem(title: "Quit Prole Tools", action: #selector(NSApp.terminate(_:)), keyEquivalent: "q")
|
||||
appMenu.addItem(quit)
|
||||
|
||||
appMenuItem.submenu = appMenu
|
||||
|
||||
// Add a standard Edit menu so Copy/Select All route to the first responder (e.g., our NSTextView)
|
||||
let editMenuItem = NSMenuItem()
|
||||
mainMenu.addItem(editMenuItem)
|
||||
let editMenu = NSMenu(title: "Edit")
|
||||
// Standard items; targets left nil so they go to first responder
|
||||
let copy = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
copy.keyEquivalentModifierMask = [.command]
|
||||
editMenu.addItem(copy)
|
||||
let selectAll = NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a")
|
||||
selectAll.keyEquivalentModifierMask = [.command]
|
||||
editMenu.addItem(selectAll)
|
||||
editMenuItem.submenu = editMenu
|
||||
|
||||
NSApp.mainMenu = mainMenu
|
||||
}
|
||||
|
||||
private func updateApplicationMenuTitles() {
|
||||
if let item = appMenuToggleStatusBarItem {
|
||||
// When we're showing the app window, offer to "Show Status Bar" (i.e., switch to status bar mode)
|
||||
// When we're in status bar mode, offer to "Show Main Window" (i.e., switch to app window mode)
|
||||
item.title = (mode == .appWindow) ? "Show Status Bar" : "Show Main Window"
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func menuToggleMode() { toggleMode() }
|
||||
@objc private func menuRefresh() { refreshNow() }
|
||||
@objc private func menuResetPortForwards() {
|
||||
// User-triggered restart remains available, but we do not auto-run it anywhere else.
|
||||
_ = PFScriptBridge.restart()
|
||||
}
|
||||
@objc private func menuPreferences() {
|
||||
if preferencesWindowController == nil {
|
||||
preferencesWindowController = PreferencesWindowController()
|
||||
}
|
||||
preferencesWindowController?.showWindow(nil)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
// Invoked by overlay maximize button
|
||||
@objc private func showMainWindowRequested() {
|
||||
dlog("AppDelegate: showMainWindowRequested notification received")
|
||||
switchToAppWindowMode()
|
||||
// Maximize (zoom) the window to ensure it's fully visible/readable
|
||||
mainWindowController.window?.zoom(nil)
|
||||
}
|
||||
}
|
||||
@ -1,283 +0,0 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
// Simple .properties loader with bundle defaults and user override support
|
||||
final class Config {
|
||||
static let shared = Config()
|
||||
|
||||
private var props: [String: String] = [:]
|
||||
static let didChangeNotification = Notification.Name("Config.didChange")
|
||||
|
||||
struct KubernetesEndpoint: Equatable, Codable {
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
struct ServiceEndpoint: Equatable, Codable {
|
||||
var name: String
|
||||
var host: String
|
||||
var port: Int
|
||||
}
|
||||
struct PortMapping: Equatable, Codable {
|
||||
var service: String
|
||||
var namespace: String
|
||||
var exposePort: Int
|
||||
var internalPort: Int
|
||||
}
|
||||
|
||||
private init() {
|
||||
// Defaults kept minimal; lists below will ensure sane defaults
|
||||
props = [:]
|
||||
|
||||
// Load bundled defaults if present
|
||||
if let url = Bundle.main.url(forResource: "prole", withExtension: "properties") {
|
||||
merge(loadProperties(url: url))
|
||||
}
|
||||
|
||||
// Load user override from Application Support
|
||||
let fm = FileManager.default
|
||||
if let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false) {
|
||||
let dir = appSupport.appendingPathComponent("Prole", isDirectory: true)
|
||||
let url = dir.appendingPathComponent("prole.properties")
|
||||
if fm.fileExists(atPath: url.path) {
|
||||
merge(loadProperties(url: url))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func merge(_ other: [String: String]) { for (k, v) in other { props[k] = v } }
|
||||
|
||||
private func loadProperties(url: URL) -> [String: String] {
|
||||
guard let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) else { return [:] }
|
||||
var result: [String: String] = [:]
|
||||
for line in text.components(separatedBy: .newlines) {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty || trimmed.hasPrefix("#") { continue }
|
||||
if let eq = trimmed.firstIndex(of: "=") {
|
||||
let key = String(trimmed[..<eq]).trimmingCharacters(in: .whitespaces)
|
||||
let value = String(trimmed[trimmed.index(after: eq)...]).trimmingCharacters(in: .whitespaces)
|
||||
if !key.isEmpty { result[key] = value }
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func string(_ key: String, default def: String) -> String { props[key] ?? def }
|
||||
func int(_ key: String, default def: Int) -> Int { Int(props[key] ?? "") ?? def }
|
||||
|
||||
// MARK: - New structured configuration
|
||||
// Kubernetes endpoints list (hostname + port). Defaults to 1 entry as requested.
|
||||
var kubernetes: [KubernetesEndpoint] {
|
||||
get {
|
||||
let items = loadIndexed(prefix: "kube") { idx in
|
||||
if let host = props["kube.\(idx).host"], !host.isEmpty {
|
||||
let port = Int(props["kube.\(idx).port"] ?? "") ?? 6443
|
||||
return KubernetesEndpoint(host: host, port: port)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !items.isEmpty { return items }
|
||||
// Defaults
|
||||
return [KubernetesEndpoint(host: "retropie.prole.org", port: 6443)]
|
||||
}
|
||||
set {
|
||||
clearIndexed(prefix: "kube")
|
||||
for (i, it) in newValue.enumerated() {
|
||||
let idx = i + 1
|
||||
props["kube.\(idx).host"] = it.host
|
||||
props["kube.\(idx).port"] = String(it.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Services list (name + hostname + port).
|
||||
var services: [ServiceEndpoint] {
|
||||
get {
|
||||
let items: [ServiceEndpoint] = loadIndexed(prefix: "svc") { idx in
|
||||
guard let name = props["svc.\(idx).name"], !name.isEmpty else { return nil }
|
||||
let host = props["svc.\(idx).host"] ?? name
|
||||
let port = Int(props["svc.\(idx).port"] ?? "") ?? 443
|
||||
return ServiceEndpoint(name: name, host: host, port: port)
|
||||
}
|
||||
// Return defaults merged with items to ensure all required services are present
|
||||
let defaults = [
|
||||
ServiceEndpoint(name: "K3D", host: "localhost", port: 6443),
|
||||
ServiceEndpoint(name: "Prometheus", host: "localhost", port: 9090),
|
||||
ServiceEndpoint(name: "Grafana", host: "localhost", port: 3000),
|
||||
ServiceEndpoint(name: "OpenBAO", host: "localhost", port: 8200),
|
||||
ServiceEndpoint(name: "Ollama", host: "localhost", port: 11434),
|
||||
ServiceEndpoint(name: "PostgreSQL", host: "localhost", port: 5432)
|
||||
]
|
||||
|
||||
if items.isEmpty { return defaults }
|
||||
|
||||
// Merge defaults into items: for each default, if it's not in items, add it.
|
||||
var result = items
|
||||
for d in defaults {
|
||||
if !result.contains(where: { $0.name.uppercased() == d.name.uppercased() }) {
|
||||
result.append(d)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
set {
|
||||
clearIndexed(prefix: "svc")
|
||||
for (i, it) in newValue.enumerated() {
|
||||
let idx = i + 1
|
||||
props["svc.\(idx).name"] = it.name
|
||||
props["svc.\(idx).host"] = it.host
|
||||
props["svc.\(idx).port"] = String(it.port)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Port mappings (service name, namespace, expose, internal)
|
||||
var portMappings: [PortMapping] {
|
||||
get {
|
||||
let items: [PortMapping] = loadIndexed(prefix: "port") { idx in
|
||||
guard let svc = props["port.\(idx).service"], !svc.isEmpty else { return nil }
|
||||
let ns = props["port.\(idx).namespace"] ?? "default"
|
||||
let expose = Int(props["port.\(idx).expose"] ?? "") ?? 0
|
||||
let internalP = Int(props["port.\(idx).internal"] ?? "") ?? 0
|
||||
return PortMapping(service: svc, namespace: ns, exposePort: expose, internalPort: internalP)
|
||||
}
|
||||
if !items.isEmpty { return items }
|
||||
return [
|
||||
PortMapping(service: "svc/kps-kube-prometheus-stack-prometheus", namespace: "monitoring", exposePort: 9090, internalPort: 9090),
|
||||
PortMapping(service: "svc/kubernetes-dashboard-kong-proxy", namespace: "kubernetes-dashboard", exposePort: 8443, internalPort: 443),
|
||||
PortMapping(service: "svc/prole-db-rw", namespace: "default", exposePort: 5432, internalPort: 5432),
|
||||
PortMapping(service: "svc/kps-grafana", namespace: "monitoring", exposePort: 3000, internalPort: 80)
|
||||
]
|
||||
}
|
||||
set {
|
||||
clearIndexed(prefix: "port")
|
||||
for (i, it) in newValue.enumerated() {
|
||||
let idx = i + 1
|
||||
props["port.\(idx).service"] = it.service
|
||||
props["port.\(idx).namespace"] = it.namespace
|
||||
props["port.\(idx).expose"] = String(it.exposePort)
|
||||
props["port.\(idx).internal"] = String(it.internalPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience computed values used by Status/Checker
|
||||
var primaryService: ServiceEndpoint? {
|
||||
// Prefer "K3D" entry, else first
|
||||
return services.first(where: { $0.name == "K3D" }) ?? services.first
|
||||
}
|
||||
var localService: ServiceEndpoint? {
|
||||
return services.first(where: { $0.name.lowercased() == "k3d" })
|
||||
}
|
||||
var primaryKube: KubernetesEndpoint? { kubernetes.first }
|
||||
|
||||
// UI background config was removed along with the splash screen.
|
||||
|
||||
// Dev port-forward supervision
|
||||
var pfEnabled: Bool {
|
||||
let v = (props["pf.enabled"] ?? "").lowercased()
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
// Background/foreground mode for port-forward launcher
|
||||
// Defaults to background to honor daemon-style commands.
|
||||
var pfModeBackground: Bool {
|
||||
let v = (props["pf.mode"] ?? "background").lowercased()
|
||||
// Allow synonyms
|
||||
if v == "bg" || v == "background" || v == "daemon" { return true }
|
||||
if v == "fg" || v == "foreground" { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
// Keepalive wrapper for port-forward commands. If true, a shell loop will
|
||||
// keep restarting the child command on exit and keep the wrapper process alive.
|
||||
// Default: true (so UI remains stable and processes behave like daemons).
|
||||
var pfKeepAlive: Bool {
|
||||
let v = (props["pf.keepalive"] ?? "true").lowercased()
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
// Enable verbose debug logging for PortForwardManager and related startup.
|
||||
// Default: false. Set pf.debug=true to print detailed diagnostics.
|
||||
var pfDebug: Bool {
|
||||
let v = (props["pf.debug"] ?? "false").lowercased()
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
// Discover and adopt already-running matching port-forward processes at startup/reset
|
||||
// Default: true
|
||||
var pfDiscovery: Bool {
|
||||
let v = (props["pf.discovery"] ?? "true").lowercased()
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
// Adopt existing matching processes instead of launching duplicates
|
||||
// Default: true
|
||||
var pfAdoptExisting: Bool {
|
||||
let v = (props["pf.adoptExisting"] ?? "true").lowercased()
|
||||
return v == "1" || v == "true" || v == "yes" || v == "on"
|
||||
}
|
||||
|
||||
// Optional kubeconfig path to export as KUBECONFIG for child processes
|
||||
var kubeconfigPath: String? {
|
||||
let v = (props["kubeconfig.path"] ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return v.isEmpty ? nil : v
|
||||
}
|
||||
|
||||
var pfCommands: [String] {
|
||||
// Collect keys pf.1, pf.2, ... in ascending order
|
||||
let keys = props.keys
|
||||
.filter { $0.hasPrefix("pf.") }
|
||||
.compactMap { k -> (Int, String)? in
|
||||
let tail = k.dropFirst(3)
|
||||
if let idx = Int(tail) { return (idx, k) }
|
||||
return nil
|
||||
}
|
||||
.sorted { $0.0 < $1.0 }
|
||||
.map { $0.1 }
|
||||
return keys.compactMap { props[$0] }
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
}
|
||||
|
||||
// MARK: - Save & helpers
|
||||
func save() {
|
||||
// Ensure Application Support/Prole exists
|
||||
let fm = FileManager.default
|
||||
guard let appSupport = try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) else { return }
|
||||
let dir = appSupport.appendingPathComponent("Prole", isDirectory: true)
|
||||
if !fm.fileExists(atPath: dir.path) {
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
let url = dir.appendingPathComponent("prole.properties")
|
||||
var lines: [String] = []
|
||||
let sortedKeys = props.keys.sorted()
|
||||
for k in sortedKeys {
|
||||
if let v = props[k] { lines.append("\(k)=\(v)") }
|
||||
}
|
||||
let text = lines.joined(separator: "\n") + "\n"
|
||||
try? text.data(using: .utf8)?.write(to: url)
|
||||
NotificationCenter.default.post(name: Config.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
// Collect 1..N until a gap of 3 is found
|
||||
private func loadIndexed<T>(prefix: String, map: (Int) -> T?) -> [T] {
|
||||
var items: [T] = []
|
||||
var idx = 1
|
||||
var gaps = 0
|
||||
while gaps < 3 {
|
||||
if let v = map(idx) {
|
||||
items.append(v)
|
||||
gaps = 0
|
||||
} else {
|
||||
gaps += 1
|
||||
}
|
||||
idx += 1
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private func clearIndexed(prefix: String) {
|
||||
let keys = props.keys.filter { $0.hasPrefix("\(prefix).") }
|
||||
for k in keys { props.removeValue(forKey: k) }
|
||||
}
|
||||
}
|
||||
@ -1,247 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// DBStatusWindowController displays the output of "kubecolor cnpg status prole-db".
|
||||
// Uses identical style and behavior as MainWindowController.
|
||||
final class DBStatusWindowController: NSWindowController {
|
||||
struct Actions {
|
||||
let onMinimizeToStatusBar: () -> Void
|
||||
}
|
||||
|
||||
private let outputTextView: NSTextView = {
|
||||
let tv = NSTextView()
|
||||
tv.isEditable = false
|
||||
tv.isSelectable = true
|
||||
tv.isRichText = false
|
||||
tv.usesFindBar = true
|
||||
tv.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
tv.textColor = .labelColor
|
||||
tv.backgroundColor = .textBackgroundColor
|
||||
tv.translatesAutoresizingMaskIntoConstraints = true
|
||||
tv.isVerticallyResizable = true
|
||||
tv.isHorizontallyResizable = false
|
||||
tv.minSize = NSSize(width: 0, height: 0)
|
||||
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
tv.textContainerInset = NSSize(width: 6, height: 8)
|
||||
if let tc = tv.textContainer {
|
||||
tc.widthTracksTextView = true
|
||||
tc.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
}
|
||||
let m = NSMenu(title: "Context")
|
||||
let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
copyItem.keyEquivalentModifierMask = [.command]
|
||||
copyItem.target = nil
|
||||
m.addItem(copyItem)
|
||||
m.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
|
||||
tv.menu = m
|
||||
return tv
|
||||
}()
|
||||
private let scrollView: NSScrollView = {
|
||||
let sv = NSScrollView()
|
||||
sv.hasVerticalScroller = true
|
||||
sv.hasHorizontalScroller = false
|
||||
sv.autohidesScrollers = true
|
||||
sv.translatesAutoresizingMaskIntoConstraints = false
|
||||
return sv
|
||||
}()
|
||||
private let intervalPopup: NSPopUpButton = {
|
||||
let p = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||
p.translatesAutoresizingMaskIntoConstraints = false
|
||||
p.addItems(withTitles: ["10s", "30s", "1 min", "5 min"])
|
||||
p.selectItem(withTitle: "30s")
|
||||
p.toolTip = "Auto-refresh interval"
|
||||
return p
|
||||
}()
|
||||
private var refreshTimer: Timer?
|
||||
private let actions: Actions
|
||||
|
||||
init(actions: Actions) {
|
||||
self.actions = actions
|
||||
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
let initialRect = NSRect(x: 0, y: 0, width: 965, height: 630)
|
||||
let window = NSWindow(contentRect: initialRect, styleMask: style, backing: .buffered, defer: false)
|
||||
super.init(window: window)
|
||||
|
||||
window.isReleasedWhenClosed = false
|
||||
window.title = "Prole Database Status"
|
||||
window.level = .normal
|
||||
window.collectionBehavior = [.canJoinAllSpaces]
|
||||
window.appearance = NSAppearance(named: .aqua)
|
||||
window.delegate = self
|
||||
|
||||
let content = NSView()
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
window.contentView = content
|
||||
|
||||
let headerLabel: NSTextField = {
|
||||
let tf = NSTextField(labelWithString: "Prole — Database Status")
|
||||
tf.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
return tf
|
||||
}()
|
||||
content.addSubview(headerLabel)
|
||||
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.drawsBackground = true
|
||||
scrollView.backgroundColor = .textBackgroundColor
|
||||
scrollView.documentView = outputTextView
|
||||
outputTextView.frame = scrollView.contentView.bounds
|
||||
outputTextView.autoresizingMask = [.width, .height]
|
||||
content.addSubview(scrollView)
|
||||
content.addSubview(intervalPopup)
|
||||
|
||||
let controls = NSStackView()
|
||||
controls.orientation = .horizontal
|
||||
controls.spacing = 8
|
||||
controls.alignment = .centerY
|
||||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(controls)
|
||||
|
||||
let minimizeButton = NSButton(title: "_", target: self, action: #selector(didTapMinimize))
|
||||
minimizeButton.bezelStyle = .texturedRounded
|
||||
minimizeButton.toolTip = "Minimize to Status Bar"
|
||||
controls.addArrangedSubview(minimizeButton)
|
||||
|
||||
let guide = window.contentLayoutGuide as? NSLayoutGuide
|
||||
let topAnchorRef = guide?.topAnchor ?? content.topAnchor
|
||||
let leadingAnchorRef = guide?.leadingAnchor ?? content.leadingAnchor
|
||||
let trailingAnchorRef = guide?.trailingAnchor ?? content.trailingAnchor
|
||||
let bottomAnchorRef = guide?.bottomAnchor ?? content.bottomAnchor
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
headerLabel.topAnchor.constraint(equalTo: topAnchorRef, constant: 12),
|
||||
headerLabel.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 12),
|
||||
headerLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchorRef, constant: -12),
|
||||
|
||||
intervalPopup.centerYAnchor.constraint(equalTo: headerLabel.centerYAnchor),
|
||||
intervalPopup.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -12),
|
||||
|
||||
scrollView.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 8),
|
||||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||||
scrollView.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
|
||||
scrollView.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -44),
|
||||
|
||||
controls.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||||
controls.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -8)
|
||||
])
|
||||
|
||||
intervalPopup.target = self
|
||||
intervalPopup.action = #selector(didChangeInterval)
|
||||
|
||||
runAndDisplay()
|
||||
resetTimer()
|
||||
DispatchQueue.main.async { [weak self] in self?.adjustFontToFit() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func show() {
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
if let tv = outputTextView.window { tv.makeFirstResponder(outputTextView) }
|
||||
}
|
||||
|
||||
func hide() { window?.orderOut(nil) }
|
||||
var isVisible: Bool { window?.isVisible ?? false }
|
||||
|
||||
func position(relativeTo refWindow: NSWindow, offset: CGPoint = CGPoint(x: -20, y: -20)) {
|
||||
guard let this = window else { return }
|
||||
let refFrame = refWindow.frame
|
||||
var newFrame = this.frame
|
||||
newFrame.origin.x = refFrame.origin.x + offset.x
|
||||
newFrame.origin.y = refFrame.origin.y + offset.y
|
||||
this.setFrame(newFrame, display: true, animate: false)
|
||||
}
|
||||
|
||||
@objc private func didTapMinimize() { actions.onMinimizeToStatusBar() }
|
||||
@objc private func didChangeInterval() { resetTimer() }
|
||||
|
||||
private func selectedIntervalSeconds() -> TimeInterval {
|
||||
switch intervalPopup.titleOfSelectedItem {
|
||||
case "10s": return 10
|
||||
case "30s": return 30
|
||||
case "1 min": return 60
|
||||
case "5 min": return 300
|
||||
default: return 30
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = selectedIntervalSeconds()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.runAndDisplay()
|
||||
}
|
||||
RunLoop.main.add(refreshTimer!, forMode: .common)
|
||||
}
|
||||
|
||||
private func runAndDisplay() {
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.outputTextView.string = "Loading database status…"
|
||||
self?.outputTextView.scrollToBeginningOfDocument(nil)
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let res = PFScriptBridge.dbStatus()
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
var text = res.out + (res.err.isEmpty ? "" : (res.out.isEmpty ? res.err : "\n" + res.err))
|
||||
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
text = "(no output received)"
|
||||
}
|
||||
if res.code != 0 { text = "[exit code: \(res.code)]\n" + text }
|
||||
let final = "# kubecolor cnpg status prole-db — status (\(now))\n\n" + text
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.outputTextView.string = final
|
||||
self?.outputTextView.scrollToBeginningOfDocument(nil)
|
||||
self?.adjustFontToFit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DBStatusWindowController: NSWindowDelegate {
|
||||
func windowDidResize(_ notification: Notification) { adjustFontToFit() }
|
||||
}
|
||||
|
||||
private extension DBStatusWindowController {
|
||||
func adjustFontToFit() {
|
||||
guard let font = outputTextView.font,
|
||||
let tc = outputTextView.textContainer,
|
||||
let lm = outputTextView.layoutManager else { return }
|
||||
|
||||
let availableHeight = scrollView.contentView.bounds.height
|
||||
guard availableHeight > 0 else { return }
|
||||
|
||||
func contentHeight(for size: CGFloat) -> CGFloat {
|
||||
outputTextView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: lm.numberOfGlyphs), actualCharacterRange: nil)
|
||||
lm.ensureLayout(for: tc)
|
||||
let used = lm.usedRect(for: tc)
|
||||
return used.height + outputTextView.textContainerInset.height * 2.0
|
||||
}
|
||||
|
||||
var minSize: CGFloat = 9
|
||||
var maxSize: CGFloat = 12
|
||||
let cap: CGFloat = 16
|
||||
|
||||
var best = maxSize
|
||||
var h = contentHeight(for: best)
|
||||
if h > availableHeight {
|
||||
var s = best
|
||||
while s > minSize {
|
||||
let next = max(minSize, s - 0.5)
|
||||
let nh = contentHeight(for: next)
|
||||
if nh <= availableHeight { best = next; break }
|
||||
s = next
|
||||
}
|
||||
} else {
|
||||
var s = best
|
||||
while s < cap {
|
||||
let next = min(cap, s + 0.5)
|
||||
let nh = contentHeight(for: next)
|
||||
if nh > availableHeight { break }
|
||||
best = next; s = next
|
||||
}
|
||||
}
|
||||
outputTextView.font = NSFont.monospacedSystemFont(ofSize: best, weight: .regular)
|
||||
outputTextView.scrollToBeginningOfDocument(nil)
|
||||
}
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum Debug {
|
||||
static let isEnabled: Bool = true
|
||||
|
||||
static func log(_ message: @autoclosure () -> String) {
|
||||
guard isEnabled else { return }
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
FileHandle.standardOutput.write(("[ProleStatus] " + ts + " " + message() + "\n").data(using: .utf8)!)
|
||||
}
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func dlog(_ message: @autoclosure () -> String) {
|
||||
Debug.log(message())
|
||||
}
|
||||
@ -1,34 +0,0 @@
|
||||
import AppKit
|
||||
import Carbon.HIToolbox
|
||||
|
||||
final class HotKeyManager {
|
||||
private var hotKeyRef: EventHotKeyRef?
|
||||
|
||||
enum Key: UInt32 { case p = 35 /* US keyboard virtual keycode for P */ }
|
||||
|
||||
struct Modifiers: OptionSet {
|
||||
let rawValue: UInt32
|
||||
static let command = Modifiers(rawValue: UInt32(cmdKey))
|
||||
static let option = Modifiers(rawValue: UInt32(optionKey))
|
||||
static let shift = Modifiers(rawValue: UInt32(shiftKey))
|
||||
}
|
||||
|
||||
func registerGlobalHotKey(modifiers: Modifiers, key: Key, handler: @escaping () -> Void) {
|
||||
var eventSpec = EventTypeSpec(eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed))
|
||||
InstallEventHandler(GetApplicationEventTarget(), { (_, _, userData) -> OSStatus in
|
||||
let handler = Unmanaged<HotKeyHandlerWrapper>.fromOpaque(userData!).takeUnretainedValue()
|
||||
handler.handler()
|
||||
return noErr
|
||||
}, 1, &eventSpec, Unmanaged.passRetained(HotKeyHandlerWrapper(handler: handler)).toOpaque(), nil)
|
||||
|
||||
let hotKeyID = EventHotKeyID(signature: OSType(UInt32(bitPattern: Int32(bitPattern: 0x50524F4C))), id: 1) // 'PROL'
|
||||
RegisterEventHotKey(UInt32(key.rawValue), modifiers.rawValue, hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef)
|
||||
}
|
||||
|
||||
deinit { if let ref = hotKeyRef { UnregisterEventHotKey(ref) } }
|
||||
}
|
||||
|
||||
private final class HotKeyHandlerWrapper {
|
||||
let handler: () -> Void
|
||||
init(handler: @escaping () -> Void) { self.handler = handler }
|
||||
}
|
||||
@ -1,323 +0,0 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import IRC
|
||||
|
||||
// A secondary window for IRC chat: short vertically, long horizontally.
|
||||
// Title: "Prole IRC". Positioned below the main Prole Status window.
|
||||
final class IRCWindowController: NSWindowController {
|
||||
private let serverField: NSTextField = {
|
||||
let tf = NSTextField(string: "localhost")
|
||||
tf.placeholderString = "Server"
|
||||
// Approximate 32-character width using a monospaced font
|
||||
tf.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
return tf
|
||||
}()
|
||||
private let sslCheckbox: NSButton = {
|
||||
let cb = NSButton(checkboxWithTitle: "SSL", target: nil, action: nil)
|
||||
cb.translatesAutoresizingMaskIntoConstraints = false
|
||||
return cb
|
||||
}()
|
||||
private let connectButton: NSButton = {
|
||||
let b = NSButton(title: "#prole", target: nil, action: nil)
|
||||
b.bezelStyle = .rounded
|
||||
b.translatesAutoresizingMaskIntoConstraints = false
|
||||
return b
|
||||
}()
|
||||
private let portSuffixLabel: NSTextField = {
|
||||
let tf = NSTextField(labelWithString: ":6667")
|
||||
tf.font = NSFont.monospacedDigitSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
|
||||
tf.textColor = .secondaryLabelColor
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
return tf
|
||||
}()
|
||||
private let transcriptView = IRCTranscriptView()
|
||||
|
||||
// Persistence keys
|
||||
private let defaults = UserDefaults.standard
|
||||
private let kServerKey = "irc.server"
|
||||
private let kSSLKey = "irc.ssl"
|
||||
|
||||
private var ircClient: IRCClient?
|
||||
private var joinedDefaultChannel = false
|
||||
|
||||
init(initialServer: String? = nil, initialSSL: Bool? = nil) {
|
||||
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
// Start with the same width as the Main (Prole Status) window (720)
|
||||
let initialRect = NSRect(x: 0, y: 0, width: 720, height: 300)
|
||||
let window = NSWindow(contentRect: initialRect, styleMask: style, backing: .buffered, defer: false)
|
||||
super.init(window: window)
|
||||
|
||||
window.isReleasedWhenClosed = false
|
||||
window.title = "Prole Tools IRC"
|
||||
window.level = .normal
|
||||
window.collectionBehavior = [.canJoinAllSpaces]
|
||||
window.appearance = NSAppearance(named: .aqua)
|
||||
window.minSize = NSSize(width: 480, height: 200)
|
||||
|
||||
// Restore persisted values
|
||||
let savedServer = initialServer ?? defaults.string(forKey: kServerKey) ?? "localhost"
|
||||
let savedSSL = initialSSL ?? defaults.bool(forKey: kSSLKey)
|
||||
serverField.stringValue = savedServer
|
||||
sslCheckbox.state = savedSSL ? .on : .off
|
||||
updatePortSuffix()
|
||||
|
||||
// Layout
|
||||
let content = NSView()
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
window.contentView = content
|
||||
|
||||
let topBar = NSStackView()
|
||||
topBar.orientation = .horizontal
|
||||
topBar.alignment = .centerY
|
||||
topBar.spacing = 8
|
||||
topBar.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(topBar)
|
||||
|
||||
// Make the server field flexible (no fixed minimum). It should stretch to fill available space.
|
||||
serverField.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
serverField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
connectButton.setContentHuggingPriority(.required, for: .horizontal)
|
||||
connectButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
sslCheckbox.setContentHuggingPriority(.required, for: .horizontal)
|
||||
sslCheckbox.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
portSuffixLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
portSuffixLabel.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
|
||||
|
||||
topBar.addArrangedSubview(NSTextField(labelWithString: "Server:"))
|
||||
topBar.addArrangedSubview(serverField)
|
||||
topBar.addArrangedSubview(portSuffixLabel)
|
||||
topBar.addArrangedSubview(sslCheckbox)
|
||||
topBar.addArrangedSubview(connectButton)
|
||||
|
||||
// Transcript fills remainder
|
||||
transcriptView.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(transcriptView)
|
||||
|
||||
let layoutGuide = window.contentLayoutGuide as Any?
|
||||
let guide = (layoutGuide as? NSLayoutGuide)
|
||||
let topAnchorRef = guide?.topAnchor ?? content.topAnchor
|
||||
let leadingAnchorRef = guide?.leadingAnchor ?? content.leadingAnchor
|
||||
let trailingAnchorRef = guide?.trailingAnchor ?? content.trailingAnchor
|
||||
let bottomAnchorRef = guide?.bottomAnchor ?? content.bottomAnchor
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
topBar.topAnchor.constraint(equalTo: topAnchorRef, constant: 8),
|
||||
topBar.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||||
// Make the top bar span full width so its children (server field) can stretch
|
||||
topBar.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
|
||||
|
||||
transcriptView.topAnchor.constraint(equalTo: topBar.bottomAnchor, constant: 8),
|
||||
transcriptView.leadingAnchor.constraint(equalTo: leadingAnchorRef),
|
||||
transcriptView.trailingAnchor.constraint(equalTo: trailingAnchorRef),
|
||||
transcriptView.bottomAnchor.constraint(equalTo: bottomAnchorRef)
|
||||
])
|
||||
|
||||
// Wire actions
|
||||
sslCheckbox.target = self; sslCheckbox.action = #selector(didToggleSSL)
|
||||
connectButton.target = self; connectButton.action = #selector(didTapConnect)
|
||||
|
||||
connectButton.isEnabled = true
|
||||
connectButton.toolTip = "Connect to #prole"
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func show() {
|
||||
window?.makeKeyAndOrderFront(nil)
|
||||
NSApp.activate(ignoringOtherApps: false)
|
||||
}
|
||||
func hide() { window?.orderOut(nil) }
|
||||
var isVisible: Bool { window?.isVisible ?? false }
|
||||
|
||||
// Position this window directly below the reference window, left-aligned.
|
||||
func position(below refWindow: NSWindow, gap: CGFloat = 8) {
|
||||
guard let this = window else { return }
|
||||
let refFrame = refWindow.frame
|
||||
var newFrame = this.frame
|
||||
// Match width to the reference (Prole Status) window
|
||||
newFrame.size.width = refFrame.size.width
|
||||
newFrame.origin.x = refFrame.origin.x
|
||||
newFrame.origin.y = refFrame.origin.y - newFrame.height - gap
|
||||
this.setFrame(newFrame, display: true, animate: false)
|
||||
}
|
||||
|
||||
@objc private func didToggleSSL() {
|
||||
updatePortSuffix()
|
||||
}
|
||||
|
||||
private func updatePortSuffix() {
|
||||
let ssl = (sslCheckbox.state == .on)
|
||||
portSuffixLabel.stringValue = ssl ? ":6697" : ":6667"
|
||||
}
|
||||
|
||||
@objc private func didTapConnect() {
|
||||
let ssl = (sslCheckbox.state == .on)
|
||||
let raw = serverField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let host = IRCWindowController.stripPort(from: raw)
|
||||
let port = ssl ? 6697 : 6667
|
||||
|
||||
// Persist
|
||||
defaults.set(host, forKey: kServerKey)
|
||||
defaults.set(ssl, forKey: kSSLKey)
|
||||
|
||||
connectTo(host: host, port: port, ssl: ssl)
|
||||
}
|
||||
|
||||
private static func stripPort(from server: String) -> String {
|
||||
if let idx = server.lastIndex(of: ":") {
|
||||
// Only strip if it looks like host:port and port is digits
|
||||
let after = server[server.index(after: idx)...]
|
||||
if after.allSatisfy({ $0.isNumber }) {
|
||||
return String(server[..<idx])
|
||||
}
|
||||
}
|
||||
return server
|
||||
}
|
||||
|
||||
private func nickname() -> String {
|
||||
let base = Host.current().localizedName ?? "prole"
|
||||
let sanitized = base.replacingOccurrences(of: "[^A-Za-z0-9]", with: "-", options: .regularExpression)
|
||||
return "prole-\(sanitized)"
|
||||
}
|
||||
|
||||
private func connectTo(host: String, port: Int, ssl: Bool) {
|
||||
// Note: TLS/SSL is not yet supported by the swift-nio-irc-client package here.
|
||||
if ssl {
|
||||
transcriptView.appendLine("Note: SSL/TLS not implemented in current IRC client; attempting plain connection…")
|
||||
}
|
||||
let nick = nickname()
|
||||
let options = IRCClientOptions(
|
||||
port: port,
|
||||
host: host,
|
||||
password: nil,
|
||||
nickname: IRCNickName(nick)!,
|
||||
userInfo: IRCUserInfo(username: nick, hostname: host, servername: host, realname: "Prole Tools")
|
||||
)
|
||||
let client = IRCClient(options: options)
|
||||
self.ircClient = client
|
||||
self.joinedDefaultChannel = false
|
||||
|
||||
// Delegate callbacks
|
||||
class Delegate: IRCClientDelegate {
|
||||
weak var owner: IRCWindowController?
|
||||
init(owner: IRCWindowController) { self.owner = owner }
|
||||
|
||||
func client(_ client: IRCClient, registered nick: IRCNickName, with userInfo: IRCUserInfo) {
|
||||
owner?.transcriptView.appendLine("Registered as \(nick.stringValue)")
|
||||
// Join default channel
|
||||
if owner?.joinedDefaultChannel == false {
|
||||
client.send(.otherCommand("JOIN", ["#prole"]))
|
||||
owner?.joinedDefaultChannel = true
|
||||
}
|
||||
}
|
||||
func clientFailedToRegister(_ client: IRCClient) {
|
||||
owner?.transcriptView.appendLine("Failed to register with server")
|
||||
}
|
||||
func client(_ client: IRCClient, received message: IRCMessage) {
|
||||
// Render a few common messages
|
||||
switch message.command {
|
||||
case .PRIVMSG(let target, let text):
|
||||
// The upstream message model may not expose a prefix property consistently across versions.
|
||||
// Fallback to unknown sender for now.
|
||||
let from = "?"
|
||||
if case .channel(let ch) = target.first {
|
||||
owner?.transcriptView.appendLine("[\(ch)] <\(from)> \(text)")
|
||||
} else {
|
||||
owner?.transcriptView.appendLine("<\(from)> \(text)")
|
||||
}
|
||||
case .NOTICE(_, let text):
|
||||
owner?.transcriptView.appendLine("-notice- \(text)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
func client(_ client: IRCClient, messageOfTheDay: String) {
|
||||
owner?.transcriptView.appendLine("-motd- \(messageOfTheDay)")
|
||||
}
|
||||
func client(_ client: IRCClient, notice message: String, for recipients: [IRCMessageRecipient]) {
|
||||
owner?.transcriptView.appendLine("-notice- \(message)")
|
||||
}
|
||||
func client(_ client: IRCClient, message: String, from user: IRCUserID, for recipients: [IRCMessageRecipient]) {
|
||||
let who = user.nick.stringValue
|
||||
owner?.transcriptView.appendLine("<\(who)> \(message)")
|
||||
}
|
||||
func client(_ client: IRCClient, changedUserModeTo mode: IRCUserMode) {}
|
||||
func client(_ client: IRCClient, changedNickTo nick: IRCNickName) {
|
||||
owner?.transcriptView.appendLine("You are now known as \(nick.stringValue)")
|
||||
}
|
||||
func client(_ client: IRCClient, user: IRCUserID, joined: [IRCChannelName]) {
|
||||
let who = user.nick.stringValue
|
||||
let channels = joined.map { $0.stringValue }.joined(separator: ", ")
|
||||
owner?.transcriptView.appendLine("-- \(who) joined \(channels)")
|
||||
}
|
||||
func client(_ client: IRCClient, user: IRCUserID, left: [IRCChannelName], with: String?) {
|
||||
let who = user.nick.stringValue
|
||||
let channels = left.map { $0.stringValue }.joined(separator: ", ")
|
||||
owner?.transcriptView.appendLine("-- \(who) left \(channels)")
|
||||
}
|
||||
func client(_ client: IRCClient, changeTopic: String, of channel: IRCChannelName) {
|
||||
owner?.transcriptView.appendLine("-- topic for \(channel.stringValue): \(changeTopic)")
|
||||
}
|
||||
}
|
||||
|
||||
client.delegate = Delegate(owner: self)
|
||||
transcriptView.appendLine("Connecting to \(host):\(port)…")
|
||||
client.connect()
|
||||
}
|
||||
}
|
||||
|
||||
// A simple scrollable transcript view that fills its container
|
||||
final class IRCTranscriptView: NSView {
|
||||
private let scroll = NSScrollView()
|
||||
private let textView = NSTextView()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.hasHorizontalScroller = false
|
||||
scroll.borderType = .bezelBorder
|
||||
|
||||
textView.isEditable = false
|
||||
textView.isSelectable = true
|
||||
textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
textView.textContainerInset = NSSize(width: 6, height: 6)
|
||||
scroll.documentView = textView
|
||||
|
||||
addSubview(scroll)
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.topAnchor.constraint(equalTo: topAnchor),
|
||||
scroll.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
convenience init() {
|
||||
self.init(frame: .zero)
|
||||
}
|
||||
|
||||
func appendLine(_ line: String) {
|
||||
let ts = IRCTranscriptView.timestamp()
|
||||
let s = "[\(ts)] \(line)\n"
|
||||
if let storage = textView.textStorage {
|
||||
storage.append(NSAttributedString(string: s))
|
||||
} else {
|
||||
textView.string.append(s)
|
||||
}
|
||||
textView.scrollToEndOfDocument(nil)
|
||||
}
|
||||
|
||||
private static func timestamp() -> String {
|
||||
let df = DateFormatter()
|
||||
df.locale = .current
|
||||
df.timeZone = .current
|
||||
df.dateFormat = "HH:mm:ss"
|
||||
return df.string(from: Date())
|
||||
}
|
||||
}
|
||||
@ -1,90 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// Lightweight helper to manage the user LaunchAgent that runs kubectl port-forwards.
|
||||
// This replaces the in-app PortForwardManager supervision.
|
||||
final class LaunchAgentManager {
|
||||
// Filenames/paths
|
||||
private let label = "org.prole.prole-db.kpf-dev"
|
||||
private var plistURL: URL {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
return home.appendingPathComponent("Library/LaunchAgents/\(label).plist")
|
||||
}
|
||||
// Deprecated: helper script is no longer bundled. All shell commands must go via $PROLE_HOME/env.sh.
|
||||
// Leaving the placeholder to avoid breaking callers; it is unused now.
|
||||
private var helperScriptURL: URL {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
return home.appendingPathComponent("Library/Application Support/Prole/bin/prole-kpf.sh")
|
||||
}
|
||||
|
||||
// Key that stores the list of commands inside the plist (array of strings)
|
||||
private let commandsKey = "ProleCommands"
|
||||
|
||||
// Read commands from the LaunchAgent plist. Returns empty if missing.
|
||||
func readCommands() -> [String] {
|
||||
guard let data = try? Data(contentsOf: plistURL) else { return [] }
|
||||
var format = PropertyListSerialization.PropertyListFormat.xml
|
||||
guard let obj = try? PropertyListSerialization.propertyList(from: data, options: [], format: &format),
|
||||
let dict = obj as? [String: Any],
|
||||
let cmds = dict[commandsKey] as? [String] else {
|
||||
return []
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
// Write commands back to the plist, preserving other keys if present.
|
||||
func writeCommands(_ commands: [String]) {
|
||||
var dict: [String: Any] = [:]
|
||||
if let data = try? Data(contentsOf: plistURL),
|
||||
let obj = try? PropertyListSerialization.propertyList(from: data, options: [], format: nil),
|
||||
let existing = obj as? [String: Any] {
|
||||
dict = existing
|
||||
}
|
||||
dict[commandsKey] = commands
|
||||
if let out = try? PropertyListSerialization.data(fromPropertyList: dict, format: .xml, options: 0) {
|
||||
// Ensure parent directory exists
|
||||
try? FileManager.default.createDirectory(at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try? out.write(to: plistURL)
|
||||
}
|
||||
}
|
||||
|
||||
// Reload the LaunchAgent: try kickstart; if not present, bootstrap then kickstart.
|
||||
func resetAgent() {
|
||||
let uid = getuid()
|
||||
let domainTarget = "gui/\(uid)/\(label)"
|
||||
|
||||
// Try kickstart first (reload)
|
||||
let ks = run("/bin/launchctl", ["kickstart", "-k", domainTarget])
|
||||
if ks.exitCode == 0 { return }
|
||||
|
||||
// If kickstart failed, try bootout then bootstrap, then kickstart again
|
||||
_ = run("/bin/launchctl", ["bootout", "gui/\(uid)", domainTarget])
|
||||
// bootstrap requires path to plist
|
||||
_ = run("/bin/launchctl", ["bootstrap", "gui/\(uid)", plistURL.path])
|
||||
_ = run("/bin/launchctl", ["kickstart", "-k", domainTarget])
|
||||
}
|
||||
|
||||
// Deprecated: No-op. We no longer bundle or copy any init_port_forwards.sh; app uses $PROLE_HOME/env.sh.
|
||||
func ensureHelperScript() { /* no-op */ }
|
||||
|
||||
static let defaultHelperScript = ""
|
||||
|
||||
// MARK: - Helpers
|
||||
@discardableResult
|
||||
private func run(_ path: String, _ args: [String]) -> (exitCode: Int32, out: String, err: String) {
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: path)
|
||||
p.arguments = args
|
||||
let outPipe = Pipe(); let errPipe = Pipe()
|
||||
p.standardOutput = outPipe
|
||||
p.standardError = errPipe
|
||||
do { try p.run() } catch {
|
||||
return (exitCode: -1, out: "", err: String(describing: error))
|
||||
}
|
||||
p.waitUntilExit()
|
||||
let outData = outPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let outStr = String(data: outData, encoding: .utf8) ?? ""
|
||||
let errStr = String(data: errData, encoding: .utf8) ?? ""
|
||||
return (p.terminationStatus, outStr, errStr)
|
||||
}
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
final class Logger {
|
||||
static let shared = Logger()
|
||||
private let queue = DispatchQueue(label: "org.prole.tools.logger", qos: .utility)
|
||||
private var currentDateStr: String = Logger.dateString(Date())
|
||||
|
||||
private init() {
|
||||
// Ensure directories exist
|
||||
ProleEnv.bootstrap()
|
||||
// Ensure current symlink points to today's file (best effort)
|
||||
queue.async { [weak self] in self?.ensureSymlink() }
|
||||
}
|
||||
|
||||
private static func dateString(_ date: Date) -> String {
|
||||
let fmt = DateFormatter()
|
||||
fmt.locale = Locale(identifier: "en_US_POSIX")
|
||||
fmt.dateFormat = "yyyy-MM-dd"
|
||||
return fmt.string(from: date)
|
||||
}
|
||||
|
||||
private func logDir() -> URL { ProleEnv.logsDir() }
|
||||
|
||||
private func datedLogURL(for dateStr: String) -> URL {
|
||||
return logDir().appendingPathComponent("prole-tools-app-\(dateStr).log")
|
||||
}
|
||||
|
||||
private func currentLogURL() -> URL {
|
||||
return logDir().appendingPathComponent("prole-tools-app.log")
|
||||
}
|
||||
|
||||
private func ensureSymlink() {
|
||||
let fm = FileManager.default
|
||||
let target = datedLogURL(for: currentDateStr)
|
||||
let link = currentLogURL()
|
||||
// If link exists and points to target, nothing to do
|
||||
if let attrs = try? fm.attributesOfItem(atPath: link.path), attrs[.type] as? FileAttributeType == .typeSymbolicLink {
|
||||
if let dest = try? fm.destinationOfSymbolicLink(atPath: link.path), dest.hasSuffix(target.lastPathComponent) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// Recreate symlink to today's file
|
||||
_ = try? fm.removeItem(at: link)
|
||||
try? fm.createSymbolicLink(at: link, withDestinationURL: target)
|
||||
}
|
||||
|
||||
private func rotateIfNeeded(now: Date = Date()) {
|
||||
let today = Logger.dateString(now)
|
||||
if today != currentDateStr {
|
||||
currentDateStr = today
|
||||
ensureSymlink()
|
||||
}
|
||||
}
|
||||
|
||||
func info(_ message: String) { write(level: "INFO", message) }
|
||||
func error(_ message: String) { write(level: "ERROR", message) }
|
||||
|
||||
func write(level: String, _ message: String) {
|
||||
queue.async {
|
||||
self.rotateIfNeeded()
|
||||
let ts = ISO8601DateFormatter().string(from: Date())
|
||||
let line = "[\(level)] \(ts) \(message)\n"
|
||||
let fileURL = self.datedLogURL(for: self.currentDateStr)
|
||||
if let data = line.data(using: .utf8) {
|
||||
if FileManager.default.fileExists(atPath: fileURL.path) {
|
||||
if let h = try? FileHandle(forWritingTo: fileURL) {
|
||||
defer { try? h.close() }
|
||||
try? h.seekToEnd()
|
||||
try? h.write(contentsOf: data)
|
||||
}
|
||||
} else {
|
||||
try? data.write(to: fileURL, options: .atomic)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,317 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
// MainWindowController builds the regular window you can move/resize.
|
||||
// Redesigned per requirements: clear the canvas and show the output of
|
||||
// etc/init-port-forward.sh in a themed window. Includes:
|
||||
// - ⟳ Refresh (manual trigger)
|
||||
// - A refresh interval selector in the upper-right (10s, 30s [default], 1min, 5min)
|
||||
// - _ Minimize to Status Bar (switches to overlay mode)
|
||||
final class MainWindowController: NSWindowController {
|
||||
struct Actions {
|
||||
let onRefresh: () -> Void
|
||||
let onMinimizeToStatusBar: () -> Void
|
||||
}
|
||||
|
||||
// Scrollable, monospaced text view to display script output
|
||||
private let outputTextView: NSTextView = {
|
||||
let tv = NSTextView()
|
||||
tv.isEditable = false
|
||||
tv.isSelectable = true
|
||||
tv.isRichText = false // plain text; ensure standard copy works cleanly
|
||||
tv.usesFindBar = true
|
||||
// Start slightly smaller than system size; will auto-fit to window afterwards
|
||||
tv.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
tv.textColor = .labelColor
|
||||
tv.backgroundColor = .textBackgroundColor
|
||||
// Important: when used as NSScrollView.documentView, the text view should
|
||||
// use frame-based resizing rather than Auto Layout constraints. Enable
|
||||
// autoresizing mask translation so it grows with the scroll content.
|
||||
tv.translatesAutoresizingMaskIntoConstraints = true
|
||||
tv.isVerticallyResizable = true
|
||||
tv.isHorizontallyResizable = false
|
||||
tv.minSize = NSSize(width: 0, height: 0)
|
||||
tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
tv.textContainerInset = NSSize(width: 6, height: 8)
|
||||
if let tc = tv.textContainer {
|
||||
tc.widthTracksTextView = true
|
||||
tc.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
||||
}
|
||||
// Context menu with basic commands routed to first responder
|
||||
let m = NSMenu(title: "Context")
|
||||
let copyItem = NSMenuItem(title: "Copy", action: #selector(NSText.copy(_:)), keyEquivalent: "c")
|
||||
copyItem.keyEquivalentModifierMask = [.command]
|
||||
copyItem.target = nil // route to first responder
|
||||
m.addItem(copyItem)
|
||||
m.addItem(NSMenuItem(title: "Select All", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a"))
|
||||
tv.menu = m
|
||||
return tv
|
||||
}()
|
||||
private let scrollView: NSScrollView = {
|
||||
let sv = NSScrollView()
|
||||
sv.hasVerticalScroller = true
|
||||
sv.hasHorizontalScroller = false
|
||||
sv.autohidesScrollers = true
|
||||
sv.translatesAutoresizingMaskIntoConstraints = false
|
||||
return sv
|
||||
}()
|
||||
private let intervalPopup: NSPopUpButton = {
|
||||
let p = NSPopUpButton(frame: .zero, pullsDown: false)
|
||||
p.translatesAutoresizingMaskIntoConstraints = false
|
||||
p.addItems(withTitles: ["10s", "30s", "1 min", "5 min"])
|
||||
p.selectItem(withTitle: "30s")
|
||||
p.toolTip = "Auto-refresh interval"
|
||||
return p
|
||||
}()
|
||||
private var refreshTimer: Timer?
|
||||
private let actions: Actions
|
||||
|
||||
init(serviceChecker: ServiceChecker, actions: Actions, pfManager: PortForwardManager? = nil) {
|
||||
// Initialize stored properties before calling super.init
|
||||
self.actions = actions
|
||||
let style: NSWindow.StyleMask = [.titled, .closable, .miniaturizable, .resizable]
|
||||
// Adjusted default size: width -33%, height -25% from previous
|
||||
let initialRect = NSRect(x: 0, y: 0, width: 965, height: 630)
|
||||
let window = NSWindow(contentRect: initialRect, styleMask: style, backing: .buffered, defer: false)
|
||||
super.init(window: window)
|
||||
|
||||
window.isReleasedWhenClosed = false
|
||||
window.title = "Prole Status — ⌘⌥⇧P to toggle"
|
||||
window.level = .normal
|
||||
window.collectionBehavior = [.canJoinAllSpaces]
|
||||
window.appearance = NSAppearance(named: .aqua)
|
||||
window.delegate = self
|
||||
|
||||
// A plain NSView acts as the content container
|
||||
let content = NSView()
|
||||
content.translatesAutoresizingMaskIntoConstraints = false
|
||||
window.contentView = content
|
||||
|
||||
// Header label to avoid title bar overlap and give more context
|
||||
let headerLabel: NSTextField = {
|
||||
let tf = NSTextField(labelWithString: "Prole — System Status")
|
||||
tf.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
|
||||
tf.lineBreakMode = .byWordWrapping
|
||||
tf.cell?.wraps = true
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
return tf
|
||||
}()
|
||||
content.addSubview(headerLabel)
|
||||
// Prepare scroll view + text view
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.drawsBackground = true
|
||||
scrollView.backgroundColor = .textBackgroundColor
|
||||
scrollView.documentView = outputTextView
|
||||
// Make the document view track the scroll view's content size
|
||||
outputTextView.frame = scrollView.contentView.bounds
|
||||
outputTextView.autoresizingMask = [.width, .height]
|
||||
content.addSubview(scrollView)
|
||||
// Upper-right interval selector
|
||||
content.addSubview(intervalPopup)
|
||||
|
||||
// Bottom-left control bar (minimize only; refresh is always automatic)
|
||||
let controls = NSStackView()
|
||||
controls.orientation = .horizontal
|
||||
controls.spacing = 8
|
||||
controls.alignment = .centerY
|
||||
controls.translatesAutoresizingMaskIntoConstraints = false
|
||||
content.addSubview(controls)
|
||||
|
||||
let minimizeButton = NSButton(title: "_", target: nil, action: nil)
|
||||
minimizeButton.bezelStyle = .texturedRounded
|
||||
minimizeButton.toolTip = "Minimize to Status Bar"
|
||||
minimizeButton.target = self
|
||||
minimizeButton.action = #selector(didTapMinimize)
|
||||
|
||||
controls.addArrangedSubview(minimizeButton)
|
||||
|
||||
// NSWindow.contentLayoutGuide is typed as Any? on AppKit; cast safely to NSLayoutGuide.
|
||||
// Fallback to the content view's anchors if unavailable.
|
||||
let layoutGuide = window.contentLayoutGuide as Any?
|
||||
let guide = (layoutGuide as? NSLayoutGuide)
|
||||
let topAnchorRef = guide?.topAnchor ?? content.topAnchor
|
||||
let leadingAnchorRef = guide?.leadingAnchor ?? content.leadingAnchor
|
||||
let trailingAnchorRef = guide?.trailingAnchor ?? content.trailingAnchor
|
||||
let bottomAnchorRef = guide?.bottomAnchor ?? content.bottomAnchor
|
||||
NSLayoutConstraint.activate([
|
||||
// Header at the top inside the content layout guide (avoids title bar)
|
||||
headerLabel.topAnchor.constraint(equalTo: topAnchorRef, constant: 12),
|
||||
headerLabel.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 12),
|
||||
headerLabel.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchorRef, constant: -12),
|
||||
|
||||
// Interval selector at top-right, aligned with header baseline
|
||||
intervalPopup.centerYAnchor.constraint(equalTo: headerLabel.centerYAnchor),
|
||||
intervalPopup.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -12),
|
||||
|
||||
// Scroll view occupies the content area below header
|
||||
scrollView.topAnchor.constraint(equalTo: headerLabel.bottomAnchor, constant: 8),
|
||||
scrollView.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||||
scrollView.trailingAnchor.constraint(equalTo: trailingAnchorRef, constant: -8),
|
||||
scrollView.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -44),
|
||||
|
||||
// Controls pinned bottom-left inside safe area
|
||||
controls.leadingAnchor.constraint(equalTo: leadingAnchorRef, constant: 8),
|
||||
controls.bottomAnchor.constraint(equalTo: bottomAnchorRef, constant: -8)
|
||||
])
|
||||
|
||||
// Wire actions
|
||||
intervalPopup.target = self
|
||||
intervalPopup.action = #selector(didChangeInterval)
|
||||
|
||||
// Initial load and timer
|
||||
runAndDisplay()
|
||||
resetTimer()
|
||||
// Perform an initial fit shortly after layout
|
||||
DispatchQueue.main.async { [weak self] in self?.adjustFontToFit() }
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func show() {
|
||||
guard let window = window else { return }
|
||||
MainWindowController.positionWindowAtLeftEdge(window)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
// Ensure the text view becomes first responder so Cmd+C routes to it
|
||||
window.makeFirstResponder(outputTextView)
|
||||
NSApp.activate(ignoringOtherApps: false)
|
||||
}
|
||||
|
||||
func hide() {
|
||||
window?.orderOut(nil)
|
||||
}
|
||||
|
||||
var isVisible: Bool { window?.isVisible ?? false }
|
||||
|
||||
// MARK: - Positioning helpers
|
||||
static func positionWindowAtLeftEdge(_ window: NSWindow, margin: CGFloat = 12) {
|
||||
guard let screen = window.screen ?? NSScreen.main else { return }
|
||||
let vis = screen.visibleFrame
|
||||
var frame = window.frame
|
||||
// Place near left edge and below the menu bar, keep current size
|
||||
frame.origin.x = vis.origin.x + margin
|
||||
// Align top to visible frame top with small margin
|
||||
frame.origin.y = vis.maxY - frame.height - margin
|
||||
// Ensure not offscreen vertically
|
||||
if frame.origin.y < vis.origin.y + margin { frame.origin.y = vis.origin.y + margin }
|
||||
window.setFrame(frame, display: true, animate: false)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
@objc private func didTapMinimize() { actions.onMinimizeToStatusBar() }
|
||||
|
||||
// MARK: - Script output rendering and auto-refresh
|
||||
@objc private func didChangeInterval() { resetTimer() }
|
||||
|
||||
private func selectedIntervalSeconds() -> TimeInterval {
|
||||
switch intervalPopup.titleOfSelectedItem {
|
||||
case "10s": return 10
|
||||
case "30s": return 30
|
||||
case "1 min": return 60
|
||||
case "5 min": return 300
|
||||
default: return 30
|
||||
}
|
||||
}
|
||||
|
||||
private func resetTimer() {
|
||||
refreshTimer?.invalidate()
|
||||
let interval = selectedIntervalSeconds()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
self?.runAndDisplay()
|
||||
}
|
||||
RunLoop.main.add(refreshTimer!, forMode: .common)
|
||||
}
|
||||
|
||||
private func runAndDisplay() {
|
||||
// Show a quick placeholder immediately so the view is never blank
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.outputTextView.string = "Loading status…"
|
||||
self?.outputTextView.scrollToBeginningOfDocument(nil)
|
||||
}
|
||||
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let res = PFScriptBridge.status()
|
||||
let now = ISO8601DateFormatter().string(from: Date())
|
||||
var text = ""
|
||||
// Always display combined stdout + stderr; include exit code if non-zero
|
||||
text = res.out + (res.err.isEmpty ? "" : (res.out.isEmpty ? res.err : "\n" + res.err))
|
||||
if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
text = "(no output received)"
|
||||
}
|
||||
if res.code != 0 { text = "[exit code: \(res.code)]\n" + text }
|
||||
let invocation = PFScriptBridge.invocationDescription()
|
||||
let header = "# \(invocation) — status (\(now))\n\n"
|
||||
let final = header + text
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.outputTextView.string = final
|
||||
// Ensure we show the beginning of the output (header)
|
||||
self?.outputTextView.scrollToBeginningOfDocument(nil)
|
||||
self?.outputTextView.scrollRangeToVisible(NSRange(location: 0, length: 0))
|
||||
self?.adjustFontToFit()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Auto-fit font to keep full output visible
|
||||
extension MainWindowController: NSWindowDelegate {
|
||||
func windowDidResize(_ notification: Notification) {
|
||||
adjustFontToFit()
|
||||
}
|
||||
}
|
||||
|
||||
private extension MainWindowController {
|
||||
func adjustFontToFit() {
|
||||
guard let font = outputTextView.font,
|
||||
let tc = outputTextView.textContainer,
|
||||
let lm = outputTextView.layoutManager else { return }
|
||||
|
||||
// Available height inside the scroll content
|
||||
let availableHeight = scrollView.contentView.bounds.height
|
||||
guard availableHeight > 0 else { return }
|
||||
|
||||
// Helper to measure content height for a given font size
|
||||
func contentHeight(for size: CGFloat) -> CGFloat {
|
||||
outputTextView.font = NSFont.monospacedSystemFont(ofSize: size, weight: .regular)
|
||||
// Invalidate layout and measure
|
||||
lm.invalidateLayout(forCharacterRange: NSRange(location: 0, length: lm.numberOfGlyphs), actualCharacterRange: nil)
|
||||
lm.ensureLayout(for: tc)
|
||||
let used = lm.usedRect(for: tc)
|
||||
return used.height + outputTextView.textContainerInset.height * 2.0
|
||||
}
|
||||
|
||||
// Bounds for font size
|
||||
var minSize: CGFloat = 9
|
||||
var maxSize: CGFloat = max(12, font.pointSize)
|
||||
|
||||
// If there is lots of space, allow growing up to a sensible cap
|
||||
let cap: CGFloat = 16
|
||||
maxSize = min(maxSize, cap)
|
||||
|
||||
// First, try to shrink if needed
|
||||
var best = maxSize
|
||||
var h = contentHeight(for: best)
|
||||
if h > availableHeight {
|
||||
// Decrease until it fits or we hit min
|
||||
var s = best
|
||||
while s > minSize {
|
||||
let next = max(minSize, s - 0.5)
|
||||
let nh = contentHeight(for: next)
|
||||
if nh <= availableHeight { best = next; h = nh; break }
|
||||
s = next
|
||||
}
|
||||
} else {
|
||||
// Try to grow a bit (keeping content fully visible) for readability
|
||||
var s = best
|
||||
while s < cap {
|
||||
let next = min(cap, s + 0.5)
|
||||
let nh = contentHeight(for: next)
|
||||
if nh > availableHeight { break }
|
||||
best = next; h = nh; s = next
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the chosen size (already applied during measurement but ensure final value)
|
||||
outputTextView.font = NSFont.monospacedSystemFont(ofSize: best, weight: .regular)
|
||||
// Keep view scrolled to top so header stays visible after relayout
|
||||
outputTextView.scrollToBeginningOfDocument(nil)
|
||||
}
|
||||
}
|
||||
@ -1,175 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
final class OverlayWindowController: NSWindowController {
|
||||
private let statusView = StatusView()
|
||||
// Fully transparent background; no visual effect overlay
|
||||
|
||||
init(serviceChecker: ServiceChecker, pfManager: PortForwardManager? = nil) {
|
||||
let style: NSWindow.StyleMask = [.borderless]
|
||||
let window = OverlayWindow(contentRect: .zero, styleMask: style, backing: .buffered, defer: false)
|
||||
super.init(window: window)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1)
|
||||
window.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
|
||||
window.hasShadow = false
|
||||
window.backgroundColor = .clear
|
||||
window.isOpaque = false
|
||||
|
||||
statusView.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let container = TransparentContainerView()
|
||||
container.wantsLayer = true
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.postsFrameChangedNotifications = true
|
||||
container.layer?.backgroundColor = NSColor.clear.cgColor
|
||||
container.addSubview(statusView)
|
||||
|
||||
window.contentView = container
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
statusView.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
||||
statusView.centerYAnchor.constraint(equalTo: container.centerYAnchor)
|
||||
])
|
||||
|
||||
statusView.bindTo(serviceChecker: serviceChecker)
|
||||
|
||||
// Initial placement
|
||||
recomputeFrameAndVisibility()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
func showIfMenuBarVisible() { recomputeFrameAndVisibility() }
|
||||
|
||||
// Show the overlay explicitly, regardless of whether the menu bar is auto-hidden.
|
||||
// Positions the overlay at the top of the visible frame.
|
||||
func showOverlayNow() {
|
||||
guard let screen = NSScreen.main else { return }
|
||||
let frame = screen.frame
|
||||
let visible = screen.visibleFrame
|
||||
let statusThickness = NSStatusBar.system.thickness
|
||||
let overlayHeight = statusThickness + 6.0
|
||||
let y = visible.maxY - overlayHeight
|
||||
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: overlayHeight)
|
||||
window?.setFrame(rect, display: true)
|
||||
window?.orderFrontRegardless()
|
||||
window?.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1)
|
||||
dlog("Overlay window forced visible at top of visible frame")
|
||||
}
|
||||
|
||||
func hideOverlay() {
|
||||
window?.orderOut(nil)
|
||||
}
|
||||
|
||||
func recomputeFrameAndVisibility() {
|
||||
guard let screen = NSScreen.main else {
|
||||
dlog("No main screen detected; ordering window out")
|
||||
window?.orderOut(nil); return
|
||||
}
|
||||
let frame = screen.frame
|
||||
let visible = screen.visibleFrame
|
||||
let menuBarHeight = frame.maxY - visible.maxY // 0 if auto-hidden or full-screen space
|
||||
|
||||
if menuBarHeight <= 0.5 { // treat as hidden/no menu bar
|
||||
dlog("Menu bar not visible (height≈0). Hiding overlay window.")
|
||||
window?.orderOut(nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Make the overlay a bit taller for better readability than the status bar thickness
|
||||
let statusThickness = NSStatusBar.system.thickness
|
||||
let overlayHeight = statusThickness + 6.0
|
||||
let y = visible.maxY - overlayHeight
|
||||
let rect = NSRect(x: frame.minX, y: y, width: frame.width, height: overlayHeight)
|
||||
|
||||
dlog("Computed overlay frame: x=\(rect.origin.x), y=\(rect.origin.y), w=\(rect.size.width), h=\(rect.size.height) | menuBarHeight=\(menuBarHeight), statusBarThickness=\(statusThickness), overlayHeight=\(overlayHeight)")
|
||||
window?.setFrame(rect, display: true)
|
||||
window?.orderFrontRegardless()
|
||||
window?.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1)
|
||||
dlog("Overlay window ordered front")
|
||||
}
|
||||
|
||||
func showContextMenu() {
|
||||
guard let window = window, window.isVisible else { return }
|
||||
let menu = NSMenu(title: "Prole Tools")
|
||||
menu.addItem(withTitle: "Prole Tools — System Status", action: nil, keyEquivalent: "")
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(withTitle: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r").target = self
|
||||
menu.addItem(withTitle: "Restart Port Forwards", action: #selector(resetPortForwards), keyEquivalent: "").target = self
|
||||
menu.addItem(.separator())
|
||||
menu.addItem(withTitle: "Quit Prole Tools", action: #selector(quit), keyEquivalent: "q").target = self
|
||||
|
||||
let point = NSPoint(x: window.frame.midX, y: window.frame.minY)
|
||||
menu.popUp(positioning: nil, at: point, in: nil)
|
||||
}
|
||||
|
||||
@objc private func refreshNow() {
|
||||
NotificationCenter.default.post(name: ServiceChecker.forceRefreshNotification, object: nil)
|
||||
}
|
||||
|
||||
@objc private func quit() {
|
||||
dlog("Quit requested via context menu; terminating app")
|
||||
NSApp.terminate(nil)
|
||||
}
|
||||
|
||||
@objc private func resetPortForwards() {
|
||||
_ = PFScriptBridge.restart()
|
||||
}
|
||||
}
|
||||
|
||||
final class OverlayWindow: NSWindow {
|
||||
override var canBecomeKey: Bool { return false }
|
||||
override var canBecomeMain: Bool { return false }
|
||||
|
||||
// Allow interaction with the window even if it's not key
|
||||
override var acceptsMouseMovedEvents: Bool {
|
||||
get { return true }
|
||||
set { }
|
||||
}
|
||||
|
||||
override func mouseDown(with event: NSEvent) {
|
||||
dlog("OverlayWindow: mouseDown at \(event.locationInWindow)")
|
||||
super.mouseDown(with: event)
|
||||
}
|
||||
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
if event.type == .leftMouseDown {
|
||||
dlog("OverlayWindow: sendEvent .leftMouseDown at \(event.locationInWindow)")
|
||||
// Fallback: Manually check if the click is in the maximize button area
|
||||
if let contentView = self.contentView,
|
||||
let statusView = contentView.subviews.first(where: { $0 is StatusView }) as? StatusView {
|
||||
let pointInStatusView = statusView.convert(event.locationInWindow, from: nil)
|
||||
if let hitView = statusView.hitTest(pointInStatusView), hitView.toolTip == "Show Main Window" {
|
||||
dlog("OverlayWindow: Manual hit detected for maximize button via sendEvent")
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ProleStatus.showMainWindow"), object: nil)
|
||||
return // Intercepted
|
||||
}
|
||||
}
|
||||
}
|
||||
super.sendEvent(event)
|
||||
}
|
||||
|
||||
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
|
||||
super.init(contentRect: contentRect, styleMask: style, backing: backingStoreType, defer: flag)
|
||||
isOpaque = false
|
||||
backgroundColor = .clear
|
||||
// Default to NOT click-through so we can intercept clicks on the maximize button.
|
||||
// TransparentContainerView.hitTest handles passing through clicks for non-button areas.
|
||||
ignoresMouseEvents = false
|
||||
acceptsMouseMovedEvents = true
|
||||
isMovableByWindowBackground = false
|
||||
// Ensure the window level is high and it doesn't ignore mouse events
|
||||
level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel()) + 1)
|
||||
dlog("OverlayWindow initialized (borderless, transparent, level: \(level.rawValue))")
|
||||
}
|
||||
}
|
||||
|
||||
// Transparent container view that passes through mouse clicks everywhere
|
||||
// while still allowing mouse-moved events for subviews that add tracking areas.
|
||||
final class TransparentContainerView: NSView {
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
let view = super.hitTest(point)
|
||||
dlog("TransparentContainerView: hitTest at \(point) -> \(view?.description ?? "nil")")
|
||||
return view
|
||||
}
|
||||
}
|
||||
@ -1,126 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
// Bridge to invoke Prole Tools environment wrapper for status/restart.
|
||||
// Use the environment defined in env.sh and execute scripts via $PROLE_SERVICE
|
||||
// e.g. "$PROLE_SERVICE/init_port_forwards.sh <cmd>" so we don't rely on PATH.
|
||||
enum PFScriptBridge {
|
||||
// Keep the last command description actually used, for display in the UI.
|
||||
private static var lastInvocation: String = "$PROLE_SERVICE/init_port_forwards.sh"
|
||||
|
||||
/// Public accessor for display purposes. Shows the last invocation we used.
|
||||
static func invocationDescription() -> String { lastInvocation }
|
||||
|
||||
@discardableResult
|
||||
static func restart() -> (code: Int32, out: String, err: String) { runScript(arg: "restart") }
|
||||
|
||||
static func status() -> (code: Int32, out: String, err: String) { runScript(arg: "status") }
|
||||
|
||||
static func dbStatus() -> (code: Int32, out: String, err: String) {
|
||||
runRawCommand(command: "kubectl cnpg status prole-db", description: "kubectl cnpg status prole-db")
|
||||
}
|
||||
|
||||
static func cnpgStatus() -> (code: Int32, out: String, err: String) {
|
||||
runRawCommand(command: "kubectl cnpg status prole-db | head -10", description: "kubectl cnpg status prole-db | head -10")
|
||||
}
|
||||
|
||||
static func openbaoStatus() -> (code: Int32, out: String, err: String) {
|
||||
// Check if the openbao pod is Ready in the default namespace (or wherever it's deployed)
|
||||
// We look for any pod with label app=openbao and check its ready status
|
||||
let cmd = "kubectl get pods -l app=openbao -o jsonpath='{.items[*].status.containerStatuses[*].ready}'"
|
||||
return runRawCommand(command: cmd, description: "kubectl check openbao ready")
|
||||
}
|
||||
|
||||
private static func runRawCommand(command: String, description: String) -> (code: Int32, out: String, err: String) {
|
||||
lastInvocation = description
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||
p.arguments = ["-lc", command]
|
||||
// Use home as default CWD
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let home = env["HOME"] ?? NSHomeDirectory()
|
||||
let proleHomeEnv = env["PROLE_HOME"]
|
||||
p.currentDirectoryURL = URL(fileURLWithPath: proleHomeEnv ?? home)
|
||||
|
||||
Logger.shared.info("invoking raw: \(description)")
|
||||
|
||||
let outPipe = Pipe(); let errPipe = Pipe()
|
||||
p.standardOutput = outPipe
|
||||
p.standardError = errPipe
|
||||
do { try p.run() } catch {
|
||||
let errStr = String(describing: error)
|
||||
Logger.shared.error("spawn error: \(errStr)")
|
||||
return (-1, "", errStr)
|
||||
}
|
||||
p.waitUntilExit()
|
||||
let out = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let code = p.terminationStatus
|
||||
|
||||
return (code, out, err)
|
||||
}
|
||||
|
||||
private static func runScript(arg: String) -> (code: Int32, out: String, err: String) {
|
||||
let fm = FileManager.default
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let home = env["HOME"] ?? NSHomeDirectory()
|
||||
let proleHomeEnv = env["PROLE_HOME"]
|
||||
// Candidate locations for env.sh
|
||||
let envCandidates: [String] = [
|
||||
(proleHomeEnv != nil ? (proleHomeEnv! + "/env.sh") : nil),
|
||||
home + "/dev/prole/env.sh",
|
||||
].compactMap { $0 }
|
||||
|
||||
// Candidate locations for the script if we can't use env.sh
|
||||
let scriptCandidates: [String] = [
|
||||
(proleHomeEnv != nil ? (proleHomeEnv! + "/etc/init_port_forwards.sh") : nil),
|
||||
home + "/dev/prole/etc/init_port_forwards.sh",
|
||||
].compactMap { $0 }
|
||||
|
||||
// Build the command using the best available method.
|
||||
var command: String
|
||||
var cwd: String = proleHomeEnv ?? home
|
||||
if let envPath = envCandidates.first(where: { fm.fileExists(atPath: $0) }) {
|
||||
// Use env.sh to populate PROLE_* and invoke via $PROLE_SERVICE
|
||||
command = "export PROLE_HOME=\"${PROLE_HOME:-$HOME}\"; . \"\(envPath)\"; \"${PROLE_SERVICE}/init_port_forwards.sh\" \(arg)"
|
||||
lastInvocation = ". \(envPath) && $PROLE_SERVICE/init_port_forwards.sh \(arg)"
|
||||
} else if let scriptPath = scriptCandidates.first(where: { fm.fileExists(atPath: $0) }) {
|
||||
// Fallback: directly execute the repo script
|
||||
command = "\"\(scriptPath)\" \(arg)"
|
||||
lastInvocation = scriptPath + " \(arg)"
|
||||
} else {
|
||||
// Nothing found; construct a failing but explicit command for diagnostics
|
||||
command = "echo '[prole-tools] env.sh and init_port_forwards.sh not found' 1>&2; exit 127"
|
||||
lastInvocation = "<not found> init_port_forwards.sh \(arg)"
|
||||
}
|
||||
|
||||
let p = Process()
|
||||
p.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||
p.arguments = ["-lc", command]
|
||||
p.currentDirectoryURL = URL(fileURLWithPath: cwd)
|
||||
Logger.shared.info("invoking: \(lastInvocation)")
|
||||
|
||||
let outPipe = Pipe(); let errPipe = Pipe()
|
||||
p.standardOutput = outPipe
|
||||
p.standardError = errPipe
|
||||
do { try p.run() } catch {
|
||||
let errStr = String(describing: error)
|
||||
Logger.shared.error("spawn error: \(errStr)")
|
||||
return (-1, "", errStr)
|
||||
}
|
||||
p.waitUntilExit()
|
||||
let out = String(data: outPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let err = String(data: errPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
|
||||
let code = p.terminationStatus
|
||||
|
||||
// Combine to match UI exactly
|
||||
var combined = out
|
||||
if !err.isEmpty { combined += (combined.isEmpty ? "" : "\n") + err }
|
||||
let trimmedCombined = combined.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedCombined.isEmpty {
|
||||
Logger.shared.info("exit code: \(code); (no output)")
|
||||
} else {
|
||||
Logger.shared.info("output (exit=\(code)):\n\(combined)")
|
||||
}
|
||||
return (code, out, err)
|
||||
}
|
||||
}
|
||||
@ -1,483 +0,0 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
|
||||
// Concurrency note:
|
||||
// - All mutable state is confined to the private serial `queue`.
|
||||
// - Background callbacks (Task bodies, termination handlers) hop to `queue`
|
||||
// before reading/writing state. UI notifications are posted on main.
|
||||
// - We mark this type as `@unchecked Sendable` to silence Sendable-capture
|
||||
// warnings; correctness relies on the queue confinement invariant above.
|
||||
final class PortForwardManager: @unchecked Sendable {
|
||||
static let statusDidChangeNotification = Notification.Name("PortForwardManager.statusDidChange")
|
||||
|
||||
private let commands: [String]
|
||||
private var processes: [Process] = []
|
||||
// Adopted external processes (not launched by us) indexed per command
|
||||
private var adoptedPIDs: [Int32?] = []
|
||||
// If a port is blocked by a non-matching process, record it
|
||||
private var blockedBy: [(pid: Int32, cmd: String)?] = []
|
||||
// Detached async tasks supervising each launched command. We keep them to maintain lifetime.
|
||||
private var tasks: [Task<Void, Never>] = []
|
||||
private let queue = DispatchQueue(label: "org.prole.portforward", qos: .utility)
|
||||
// Deprecated periodic monitor and shell keepalive are removed in favor of detached tasks
|
||||
private var timer: DispatchSourceTimer?
|
||||
// State
|
||||
private var startTimes: [DispatchTime?] = []
|
||||
private var failureCounts: [Int] = []
|
||||
private var nextRestartAt: [DispatchTime] = []
|
||||
private var lastExitCodes: [Int32?] = []
|
||||
private var lastExitTimes: [Date?] = []
|
||||
|
||||
private let minUptimeSeconds: Double = 3.0
|
||||
|
||||
init(commands: [String]) {
|
||||
self.commands = commands
|
||||
}
|
||||
|
||||
func start() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.commands.isEmpty {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
return
|
||||
}
|
||||
self.ensureArrays()
|
||||
// Optional discovery/adoption of already-running matching processes to avoid duplicates
|
||||
if Config.shared.pfDiscovery {
|
||||
for idx in self.commands.indices { self.discoverOrAdoptIfPossible(index: idx) }
|
||||
}
|
||||
// Launch each command in its own detached async Task so UI remains responsive
|
||||
for idx in self.commands.indices {
|
||||
// Skip launching if already adopted or blocked by another process
|
||||
if (self.adoptedPIDs[safe: idx] ?? nil) != nil { continue }
|
||||
if (self.blockedBy[safe: idx] ?? nil) != nil { continue }
|
||||
self.launchDetached(index: idx)
|
||||
}
|
||||
// Periodic discovery/liveness check for adopted/blocked and auto-launch when free
|
||||
self.startDiscoveryTimer()
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
queue.async { [weak self] in
|
||||
self?.timer?.cancel(); self?.timer = nil
|
||||
for p in self?.processes ?? [] { if p.isRunning { p.terminate() } }
|
||||
self?.processes.removeAll()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
queue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.timer?.cancel(); self.timer = nil
|
||||
// Terminate only owned processes; keep adopted processes running
|
||||
for (i, p) in self.processes.enumerated() {
|
||||
if let adopted = self.adoptedPIDs[safe: i] ?? nil, adopted > 0 {
|
||||
continue
|
||||
}
|
||||
if p.isRunning { p.terminate() }
|
||||
}
|
||||
// Cancel any supervising tasks (they will end when processes terminate)
|
||||
for t in self.tasks { t.cancel() }
|
||||
self.processes.removeAll()
|
||||
self.tasks.removeAll()
|
||||
self.startTimes.removeAll()
|
||||
self.failureCounts.removeAll()
|
||||
self.nextRestartAt.removeAll()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
// Restart after a short delay to allow ports to release
|
||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.ensureArrays()
|
||||
if Config.shared.pfDiscovery {
|
||||
for idx in self.commands.indices { self.discoverOrAdoptIfPossible(index: idx) }
|
||||
}
|
||||
for idx in self.commands.indices {
|
||||
if (self.adoptedPIDs[safe: idx] ?? nil) != nil { continue }
|
||||
if (self.blockedBy[safe: idx] ?? nil) != nil { continue }
|
||||
self.launchDetached(index: idx)
|
||||
}
|
||||
self.startDiscoveryTimer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var allRunning: Bool {
|
||||
return queue.sync {
|
||||
if commands.isEmpty { return false }
|
||||
var runningCount = 0
|
||||
for i in commands.indices {
|
||||
if i < processes.count, processes[i].isRunning { runningCount += 1; continue }
|
||||
if let pid = adoptedPIDs[safe: i] ?? nil, pid > 0, isPIDAlive(pid) { runningCount += 1; continue }
|
||||
}
|
||||
return runningCount == commands.count
|
||||
}
|
||||
}
|
||||
|
||||
var runningCount: Int {
|
||||
return queue.sync {
|
||||
var c = 0
|
||||
for i in commands.indices {
|
||||
if i < processes.count, processes[i].isRunning { c += 1; continue }
|
||||
if let pid = adoptedPIDs[safe: i] ?? nil, pid > 0, isPIDAlive(pid) { c += 1 }
|
||||
}
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
struct Detail {
|
||||
let command: String
|
||||
let pid: Int32?
|
||||
let running: Bool
|
||||
let lastExitCode: Int32?
|
||||
let lastExitAt: Date?
|
||||
}
|
||||
|
||||
var details: [Detail] {
|
||||
return queue.sync {
|
||||
var out: [Detail] = []
|
||||
for (i, rawCmd) in commands.enumerated() {
|
||||
let code = i < lastExitCodes.count ? lastExitCodes[i] : nil
|
||||
let when = i < lastExitTimes.count ? lastExitTimes[i] : nil
|
||||
var cmd = rawCmd
|
||||
var pid: Int32? = nil
|
||||
var running = false
|
||||
if i < processes.count, processes[i].isRunning {
|
||||
pid = processes[i].processIdentifier
|
||||
running = true
|
||||
} else if let apid = adoptedPIDs[safe: i] ?? nil, apid > 0, isPIDAlive(apid) {
|
||||
pid = apid
|
||||
running = true
|
||||
cmd = rawCmd + " [adopted]"
|
||||
} else if let blk = blockedBy[safe: i] ?? nil {
|
||||
pid = blk.pid
|
||||
running = false
|
||||
cmd = rawCmd + " [blocked by PID \(blk.pid) \(shortProcessName(blk.cmd))]"
|
||||
}
|
||||
out.append(Detail(command: cmd, pid: pid, running: running, lastExitCode: code, lastExitAt: when))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// Per-command detached async launcher
|
||||
private func launchDetached(index idx: Int) {
|
||||
ensureArrays()
|
||||
let raw = commands[idx]
|
||||
// Per user directive: run inside a detached task and use bash -c "$cmd"
|
||||
let parsed = parsePFCommand(raw)
|
||||
// Important: ensure foreground execution so our Process stays alive (strip trailing &)
|
||||
let body = stripTrailingAmp(parsed.command)
|
||||
|
||||
let task = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
let p = self.makeSimpleBashProcess(command: body, index: idx)
|
||||
p.terminationHandler = { [weak self] proc in
|
||||
guard let self = self else { return }
|
||||
// Capture the terminationStatus from the actual Process instance
|
||||
// provided by the handler to avoid races with self.processes[idx]
|
||||
// potentially pointing at a different/non-launched Process.
|
||||
let status = proc.terminationStatus
|
||||
let when = Date()
|
||||
self.queue.async {
|
||||
if idx < self.lastExitCodes.count { self.lastExitCodes[idx] = status }
|
||||
if idx < self.lastExitTimes.count { self.lastExitTimes[idx] = when }
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
do {
|
||||
try p.run()
|
||||
self.queue.async {
|
||||
self.startTimes[idx] = .now()
|
||||
if idx < self.processes.count { self.processes[idx] = p } else { self.processes.append(p) }
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
// Wait for the long-running child; this keeps the detached Task alive
|
||||
p.waitUntilExit()
|
||||
} catch {
|
||||
dlog("PortForwardManager: failed to start: \(body) error=\(error)")
|
||||
self.queue.async {
|
||||
if idx < self.failureCounts.count { self.failureCounts[idx] += 1 }
|
||||
if idx < self.processes.count { self.processes[idx] = Process() } else { self.processes.append(Process()) }
|
||||
self.lastExitTimes[idx] = Date()
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if idx < tasks.count { tasks[idx] = task } else { tasks.append(task) }
|
||||
}
|
||||
|
||||
// No periodic monitor needed with per-task supervision
|
||||
|
||||
// Simple bash -lc "$command" process suitable for long-running foreground tasks (kubectl port-forward)
|
||||
private func makeSimpleBashProcess(command: String, index: Int) -> Process {
|
||||
let cfg = Config.shared
|
||||
let p = Process()
|
||||
p.launchPath = "/bin/bash"
|
||||
// Default PROLE_HOME and invoke the executable env.sh wrapper so the environment persists for the child
|
||||
let wrapped = "export PROLE_HOME=\"${PROLE_HOME:-$HOME/.prole}\"; \"$PROLE_HOME/env.sh\" \(command)"
|
||||
p.arguments = ["-lc", wrapped]
|
||||
p.standardInput = FileHandle(forReadingAtPath: "/dev/null")
|
||||
p.qualityOfService = .utility
|
||||
|
||||
// Env
|
||||
var env = ProcessInfo.processInfo.environment
|
||||
let defaultPATH = "/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
if let cur = env["PATH"], !cur.isEmpty {
|
||||
if !cur.contains("/opt/homebrew/bin") || !cur.contains("/usr/local/bin") {
|
||||
env["PATH"] = cur + ":" + defaultPATH
|
||||
}
|
||||
} else { env["PATH"] = defaultPATH }
|
||||
if let kube = cfg.kubeconfigPath, !kube.isEmpty { env["KUBECONFIG"] = kube }
|
||||
if env["HOME"] == nil { env["HOME"] = NSHomeDirectory() }
|
||||
if env["SHELL"] == nil { env["SHELL"] = "/bin/bash" }
|
||||
p.environment = env
|
||||
|
||||
// Logs
|
||||
let logsDir: URL
|
||||
if let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first {
|
||||
logsDir = base.appendingPathComponent("Logs/Prole", isDirectory: true)
|
||||
} else {
|
||||
logsDir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("ProleLogs", isDirectory: true)
|
||||
}
|
||||
do { try FileManager.default.createDirectory(at: logsDir, withIntermediateDirectories: true) } catch {}
|
||||
let name = String(command.hashValue, radix: 16)
|
||||
let outURL = logsDir.appendingPathComponent("portforward-\(name).log")
|
||||
if let fh = try? FileHandle(forWritingTo: outURL) {
|
||||
try? fh.truncate(atOffset: 0)
|
||||
p.standardOutput = fh
|
||||
p.standardError = fh
|
||||
} else if FileManager.default.createFile(atPath: outURL.path, contents: nil), let fh = try? FileHandle(forWritingTo: outURL) {
|
||||
p.standardOutput = fh
|
||||
p.standardError = fh
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
private func ensureArrays() {
|
||||
if processes.count != commands.count { processes = Array(processes.prefix(commands.count)) }
|
||||
if adoptedPIDs.count != commands.count { adoptedPIDs = Array(repeating: nil, count: commands.count) }
|
||||
if blockedBy.count != commands.count { blockedBy = Array(repeating: nil, count: commands.count) }
|
||||
if startTimes.count != commands.count { startTimes = Array(repeating: nil, count: commands.count) }
|
||||
if failureCounts.count != commands.count { failureCounts = Array(repeating: 0, count: commands.count) }
|
||||
if nextRestartAt.count != commands.count { nextRestartAt = Array(repeating: .now(), count: commands.count) }
|
||||
if lastExitCodes.count != commands.count { lastExitCodes = Array(repeating: nil, count: commands.count) }
|
||||
if lastExitTimes.count != commands.count { lastExitTimes = Array(repeating: nil, count: commands.count) }
|
||||
}
|
||||
|
||||
private func backoffDelay(forFailures n: Int) -> DispatchTimeInterval {
|
||||
// Exponential backoff: 0.5s, 1s, 2s, 4s, capped at 10s
|
||||
let base: Double = 0.5
|
||||
let seconds = min(10.0, base * pow(2.0, Double(max(0, n-1))))
|
||||
return .milliseconds(Int(seconds * 1000.0))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private extension PortForwardManager {
|
||||
// Attempt to discover an already-running matching PF process for the given command index.
|
||||
func discoverOrAdoptIfPossible(index idx: Int) {
|
||||
guard Config.shared.pfAdoptExisting else { return }
|
||||
let cmd = commands[idx]
|
||||
guard let port = localPort(from: cmd) else { return }
|
||||
if let hit = findListener(on: port) {
|
||||
// Determine if this looks like kubectl port-forward
|
||||
let lower = hit.cmd.lowercased()
|
||||
if lower.contains("kubectl") && lower.contains("port-forward") {
|
||||
adoptedPIDs[idx] = hit.pid
|
||||
blockedBy[idx] = nil
|
||||
if Config.shared.pfDebug { dlog("PF adopt: idx=\(idx) port=\(port) pid=\(hit.pid) cmd=\(hit.cmd)") }
|
||||
} else {
|
||||
// Port is in use by something else; mark as blocked so we don't launch and fail.
|
||||
blockedBy[idx] = hit
|
||||
adoptedPIDs[idx] = nil
|
||||
if Config.shared.pfDebug { dlog("PF blocked: idx=\(idx) port=\(port) by pid=\(hit.pid) cmd=\(hit.cmd)") }
|
||||
}
|
||||
} else {
|
||||
adoptedPIDs[idx] = nil
|
||||
blockedBy[idx] = nil
|
||||
}
|
||||
}
|
||||
|
||||
func startDiscoveryTimer() {
|
||||
timer?.cancel()
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now() + 2.0, repeating: 3.0)
|
||||
t.setEventHandler { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.ensureArrays()
|
||||
var changed = false
|
||||
for idx in self.commands.indices {
|
||||
// If we own a running process, continue
|
||||
if idx < self.processes.count, self.processes[idx].isRunning { continue }
|
||||
|
||||
// If we have an adopted PID, verify liveness; clear if dead
|
||||
if let apid = self.adoptedPIDs[safe: idx] ?? nil {
|
||||
if !self.isPIDAlive(apid) {
|
||||
self.adoptedPIDs[idx] = nil
|
||||
changed = true
|
||||
} else {
|
||||
// still alive; nothing to do
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Re-discover if enabled
|
||||
if Config.shared.pfDiscovery {
|
||||
let beforeBlocked = self.blockedBy[idx]?.pid
|
||||
self.discoverOrAdoptIfPossible(index: idx)
|
||||
if let apid = self.adoptedPIDs[idx], apid > 0 {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
// If previously blocked but now free, mark change
|
||||
if beforeBlocked != self.blockedBy[idx]?.pid { changed = true }
|
||||
}
|
||||
|
||||
// If not adopted and not blocked, and we don't currently have a running owned process, launch
|
||||
if (self.blockedBy[safe: idx] ?? nil) == nil {
|
||||
// Avoid launching repeatedly if a task is already supervising a non-running Process; ensure we create a fresh one
|
||||
self.launchDetached(index: idx)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: PortForwardManager.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
}
|
||||
self.timer = t
|
||||
t.resume()
|
||||
}
|
||||
|
||||
func isPIDAlive(_ pid: Int32) -> Bool {
|
||||
return kill(pid_t(pid), 0) == 0
|
||||
}
|
||||
|
||||
func shortProcessName(_ full: String) -> String {
|
||||
if let last = full.split(separator: "/").last { return String(last) }
|
||||
return full
|
||||
}
|
||||
|
||||
func localPort(from command: String) -> Int? {
|
||||
// Find first occurrence of pattern like "8080:xxxx"
|
||||
let pattern = #"(\s|^)(\d+):\d+"#
|
||||
if let regex = try? NSRegularExpression(pattern: pattern, options: []) {
|
||||
let ns = command as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
if let m = regex.firstMatch(in: command, options: [], range: range), m.numberOfRanges >= 3 {
|
||||
let portStr = ns.substring(with: m.range(at: 2))
|
||||
return Int(portStr)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findListener(on port: Int) -> (pid: Int32, cmd: String)? {
|
||||
// Try lsof to get PID listening on the given TCP port
|
||||
let lsofPaths = ["/usr/sbin/lsof", "/usr/bin/lsof", "/bin/lsof", "/usr/local/bin/lsof", "/opt/homebrew/sbin/lsof", "/opt/homebrew/bin/lsof", "lsof"]
|
||||
var lsofOut: String = ""
|
||||
for path in lsofPaths {
|
||||
if let out = runAndCapture(path, ["-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fp"]), !out.isEmpty {
|
||||
lsofOut = out; break
|
||||
}
|
||||
}
|
||||
guard !lsofOut.isEmpty else { return nil }
|
||||
// Parse first pid line like: p12345
|
||||
var pid: Int32 = 0
|
||||
for line in lsofOut.split(separator: "\n") {
|
||||
if line.hasPrefix("p"), let p = Int32(line.dropFirst()) { pid = p; break }
|
||||
}
|
||||
if pid <= 0 { return nil }
|
||||
// Get full command for that PID
|
||||
let psOut = runAndCapture("/bin/ps", ["-p", String(pid), "-o", "command="]) ?? ""
|
||||
let cmd = psOut.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return (pid, cmd)
|
||||
}
|
||||
|
||||
func runAndCapture(_ launchPath: String, _ args: [String]) -> String? {
|
||||
let p = Process()
|
||||
p.launchPath = launchPath
|
||||
p.arguments = args
|
||||
let pipe = Pipe()
|
||||
p.standardOutput = pipe
|
||||
p.standardError = Pipe()
|
||||
do {
|
||||
try p.run()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
p.waitUntilExit()
|
||||
let data = try? pipe.fileHandleForReading.readToEnd()
|
||||
guard let d = data, let s = String(data: d, encoding: .utf8) else { return nil }
|
||||
return s
|
||||
}
|
||||
func parsePFCommand(_ raw: String) -> (command: String, modeBackground: Bool, keepAlive: Bool) {
|
||||
var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
var bg = Config.shared.pfModeBackground
|
||||
var ka = Config.shared.pfKeepAlive
|
||||
// Support multiple prefixes in any order: bg:, fg:, ka:, noka:
|
||||
while true {
|
||||
let lower = s.lowercased()
|
||||
if lower.hasPrefix("bg:") {
|
||||
s = String(s.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
bg = true
|
||||
continue
|
||||
}
|
||||
if lower.hasPrefix("fg:") {
|
||||
s = String(s.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
bg = false
|
||||
continue
|
||||
}
|
||||
if lower.hasPrefix("ka:") {
|
||||
s = String(s.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
||||
ka = true
|
||||
continue
|
||||
}
|
||||
if lower.hasPrefix("noka:") {
|
||||
s = String(s.dropFirst(5)).trimmingCharacters(in: .whitespaces)
|
||||
ka = false
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
if !bg {
|
||||
// In foreground mode, ensure we don't leave a trailing '&'
|
||||
s = stripTrailingAmp(s)
|
||||
}
|
||||
return (s, bg, ka)
|
||||
}
|
||||
|
||||
func stripTrailingAmp(_ s: String) -> String {
|
||||
let regex = try? NSRegularExpression(pattern: "\\s*&\\s*$")
|
||||
let ns = s as NSString
|
||||
let range = NSRange(location: 0, length: ns.length)
|
||||
if let r = regex?.firstMatch(in: s, options: [], range: range) {
|
||||
let trimmed = ns.replacingCharacters(in: r.range, with: "")
|
||||
return trimmed.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
// Safe subscript to avoid index crashes inside async handlers
|
||||
private extension Array {
|
||||
subscript(safe index: Int) -> Element? {
|
||||
return indices.contains(index) ? self[index] : nil
|
||||
}
|
||||
}
|
||||
@ -1,77 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct PortMappingXML: Equatable {
|
||||
var id: String
|
||||
var namespace: String
|
||||
var target: String
|
||||
var address: String
|
||||
var hostPort: String
|
||||
var servicePort: String
|
||||
var proto: String
|
||||
var description: String
|
||||
}
|
||||
|
||||
enum PortMappingsXMLStore {
|
||||
private static func configURL() -> URL {
|
||||
// Determine PROLE_HOME similarly to PFScriptBridge
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
if let home = env["PROLE_HOME"], !home.isEmpty {
|
||||
return URL(fileURLWithPath: home).appendingPathComponent("conf/port-mappings.properties")
|
||||
}
|
||||
let defaultHome = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("dev/prole")
|
||||
return defaultHome.appendingPathComponent("conf/port-mappings.properties")
|
||||
}
|
||||
|
||||
static func load() -> [PortMappingXML] {
|
||||
let url = configURL()
|
||||
guard let data = try? Data(contentsOf: url) else { return [] }
|
||||
let parser = XMLParser(data: data)
|
||||
let delegate = ParserDelegate()
|
||||
parser.delegate = delegate
|
||||
if parser.parse() { return delegate.items }
|
||||
return []
|
||||
}
|
||||
|
||||
static func save(_ items: [PortMappingXML]) throws {
|
||||
let url = configURL()
|
||||
var xml = """
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<portMappings>
|
||||
"""
|
||||
for m in items {
|
||||
xml += " <mapping id=\"\(escape(m.id))\" namespace=\"\(escape(m.namespace))\" target=\"\(escape(m.target))\" address=\"\(escape(m.address))\" hostPort=\"\(escape(m.hostPort))\" servicePort=\"\(escape(m.servicePort))\" protocol=\"\(escape(m.proto))\" description=\"\(escape(m.description))\"/>\n"
|
||||
}
|
||||
xml += """
|
||||
</portMappings>
|
||||
"""
|
||||
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
try xml.data(using: .utf8)?.write(to: url)
|
||||
}
|
||||
|
||||
private static func escape(_ s: String) -> String {
|
||||
return s.replacingOccurrences(of: "&", with: "&")
|
||||
.replacingOccurrences(of: "\"", with: """)
|
||||
.replacingOccurrences(of: "<", with: "<")
|
||||
.replacingOccurrences(of: ">", with: ">")
|
||||
}
|
||||
|
||||
private final class ParserDelegate: NSObject, XMLParserDelegate {
|
||||
var items: [PortMappingXML] = []
|
||||
|
||||
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String : String] = [:]) {
|
||||
if elementName == "mapping" {
|
||||
let m = PortMappingXML(
|
||||
id: attributeDict["id"] ?? "",
|
||||
namespace: attributeDict["namespace"] ?? "default",
|
||||
target: attributeDict["target"] ?? "",
|
||||
address: attributeDict["address"] ?? "127.0.0.1",
|
||||
hostPort: attributeDict["hostPort"] ?? "",
|
||||
servicePort: attributeDict["servicePort"] ?? "",
|
||||
proto: attributeDict["protocol"] ?? "TCP",
|
||||
description: attributeDict["description"] ?? ""
|
||||
)
|
||||
items.append(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,587 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
final class PreferencesWindowController: NSWindowController, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSTabViewDelegate {
|
||||
private enum Tab: Int { case kubernetes = 0, services = 1, ports = 2, agentPF = 3 }
|
||||
|
||||
private var kubeItems: [Config.KubernetesEndpoint] = []
|
||||
private var serviceItems: [Config.ServiceEndpoint] = []
|
||||
private var portItems: [PortMappingXML] = []
|
||||
|
||||
private let tabView = NSTabView()
|
||||
private let launchAgentManager = LaunchAgentManager()
|
||||
private let agentCommandsTextView = NSTextView()
|
||||
private var didCenterOnce = false
|
||||
|
||||
// Tables
|
||||
private let kubeTable = NSTableView()
|
||||
private let svcTable = NSTableView()
|
||||
private let portTable = NSTableView()
|
||||
|
||||
convenience init() {
|
||||
let rect = NSRect(x: 0, y: 0, width: 640, height: 400)
|
||||
// Make Preferences window resizable to avoid cramped layouts
|
||||
let window = NSWindow(contentRect: rect, styleMask: [.titled, .closable, .resizable], backing: .buffered, defer: false)
|
||||
self.init(window: window)
|
||||
window.isReleasedWhenClosed = false
|
||||
window.title = "Preferences"
|
||||
// Ensure the window has a sensible minimum size so controls remain visible
|
||||
window.contentMinSize = NSSize(width: 520, height: 320)
|
||||
// Let macOS remember user-adjusted placement on subsequent opens
|
||||
window.setFrameAutosaveName("PreferencesWindow")
|
||||
|
||||
kubeItems = Config.shared.kubernetes
|
||||
serviceItems = Config.shared.services
|
||||
portItems = PortMappingsXMLStore.load()
|
||||
|
||||
setupUI()
|
||||
}
|
||||
|
||||
override func showWindow(_ sender: Any?) {
|
||||
super.showWindow(sender)
|
||||
// Center only on first show so subsequent shows restore autosaved frame
|
||||
if !didCenterOnce {
|
||||
window?.center()
|
||||
didCenterOnce = true
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
private func setupUI() {
|
||||
guard let content = window?.contentView else { return }
|
||||
|
||||
tabView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// Make sure tabs are at the top and styled like standard preferences
|
||||
tabView.tabPosition = .top
|
||||
if #available(macOS 11.0, *) {
|
||||
tabView.tabViewType = .topTabsBezelBorder
|
||||
} else {
|
||||
tabView.tabViewType = .topTabsBezelBorder
|
||||
}
|
||||
tabView.delegate = self
|
||||
content.addSubview(tabView)
|
||||
// Prefer the window's contentLayoutGuide to avoid title-bar overlap and ensure proper insets
|
||||
if let guide = window?.contentLayoutGuide as? NSLayoutGuide {
|
||||
NSLayoutConstraint.activate([
|
||||
tabView.leadingAnchor.constraint(equalTo: guide.leadingAnchor, constant: 12),
|
||||
tabView.trailingAnchor.constraint(equalTo: guide.trailingAnchor, constant: -12),
|
||||
tabView.topAnchor.constraint(equalTo: guide.topAnchor, constant: 12),
|
||||
tabView.bottomAnchor.constraint(equalTo: guide.bottomAnchor, constant: -12)
|
||||
])
|
||||
} else {
|
||||
NSLayoutConstraint.activate([
|
||||
tabView.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 12),
|
||||
tabView.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -12),
|
||||
tabView.topAnchor.constraint(equalTo: content.topAnchor, constant: 12),
|
||||
tabView.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -12)
|
||||
])
|
||||
}
|
||||
|
||||
// Tabs
|
||||
addKubernetesTab()
|
||||
addServicesTab()
|
||||
addPortsTab()
|
||||
addAgentPortForwardsTab()
|
||||
|
||||
// Ensure initial data is visible on all tabs
|
||||
kubeTable.reloadData()
|
||||
svcTable.reloadData()
|
||||
portTable.reloadData()
|
||||
// Load agent commands
|
||||
loadAgentCommandsIntoEditor()
|
||||
// Column sizing and layout stabilization
|
||||
sizeColumnsAndLayout(table: kubeTable)
|
||||
sizeColumnsAndLayout(table: svcTable)
|
||||
sizeColumnsAndLayout(table: portTable)
|
||||
// Ensure tab content views adopt the tab's content rect and resize with it
|
||||
syncAllTabContentFrames()
|
||||
tabView.selectTabViewItem(at: 0)
|
||||
// Observe window resizes to keep current tab content sized correctly
|
||||
if let win = window {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(windowDidResize), name: NSWindow.didResizeNotification, object: win)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Tabs
|
||||
private func addKubernetesTab() {
|
||||
let item = NSTabViewItem(identifier: Tab.kubernetes.rawValue)
|
||||
item.label = "Kubernetes"
|
||||
let view = makeTableContainer(table: kubeTable, columns: [
|
||||
(identifier: NSUserInterfaceItemIdentifier("host"), title: "Hostname", width: 360),
|
||||
(identifier: NSUserInterfaceItemIdentifier("port"), title: "Port", width: 100)
|
||||
], addAction: #selector(addKube), removeAction: #selector(removeKube), saveAction: #selector(saveKube))
|
||||
adoptTabContentSizing(view)
|
||||
item.view = view
|
||||
tabView.addTabViewItem(item)
|
||||
}
|
||||
|
||||
private func addServicesTab() {
|
||||
let item = NSTabViewItem(identifier: Tab.services.rawValue)
|
||||
item.label = "Services"
|
||||
let view = makeTableContainer(table: svcTable, columns: [
|
||||
(identifier: NSUserInterfaceItemIdentifier("name"), title: "Service Name", width: 180),
|
||||
(identifier: NSUserInterfaceItemIdentifier("host"), title: "Hostname", width: 260),
|
||||
(identifier: NSUserInterfaceItemIdentifier("port"), title: "Port", width: 100)
|
||||
], addAction: #selector(addService), removeAction: #selector(removeService), saveAction: #selector(saveService))
|
||||
adoptTabContentSizing(view)
|
||||
item.view = view
|
||||
tabView.addTabViewItem(item)
|
||||
}
|
||||
|
||||
private func addPortsTab() {
|
||||
let item = NSTabViewItem(identifier: Tab.ports.rawValue)
|
||||
item.label = "Ports"
|
||||
let view = makeTableContainer(table: portTable, columns: [
|
||||
(identifier: NSUserInterfaceItemIdentifier("service"), title: "Target (svc/deploy/pod)", width: 260),
|
||||
(identifier: NSUserInterfaceItemIdentifier("namespace"), title: "Namespace", width: 160),
|
||||
(identifier: NSUserInterfaceItemIdentifier("expose"), title: "Host Port", width: 100),
|
||||
(identifier: NSUserInterfaceItemIdentifier("internal"), title: "Service Port", width: 100)
|
||||
], addAction: #selector(addPort), removeAction: #selector(removePort), saveAction: #selector(savePort))
|
||||
adoptTabContentSizing(view)
|
||||
item.view = view
|
||||
tabView.addTabViewItem(item)
|
||||
}
|
||||
|
||||
private func addAgentPortForwardsTab() {
|
||||
let item = NSTabViewItem(identifier: Tab.agentPF.rawValue)
|
||||
item.label = "Port Forwards (Agent)"
|
||||
|
||||
let container = NSView()
|
||||
container.translatesAutoresizingMaskIntoConstraints = true
|
||||
container.autoresizingMask = [.width, .height]
|
||||
|
||||
let scroll = NSScrollView()
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.documentView = agentCommandsTextView
|
||||
agentCommandsTextView.isVerticallyResizable = true
|
||||
agentCommandsTextView.isHorizontallyResizable = true
|
||||
agentCommandsTextView.font = .monospacedSystemFont(ofSize: 12, weight: .regular)
|
||||
agentCommandsTextView.autoresizingMask = [.width, .height]
|
||||
|
||||
let helpLabel = NSTextField(labelWithString: "One kubectl command per line. These are stored in your LaunchAgent plist and run by a helper script.")
|
||||
helpLabel.lineBreakMode = .byWordWrapping
|
||||
helpLabel.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let saveButton = NSButton(title: "Save", target: self, action: #selector(saveAgentCommands))
|
||||
saveButton.bezelStyle = .rounded
|
||||
let reloadButton = NSButton(title: "Reload Agent", target: self, action: #selector(reloadAgent))
|
||||
reloadButton.bezelStyle = .rounded
|
||||
let hstack = NSStackView(views: [helpLabel, NSView(), saveButton, reloadButton])
|
||||
hstack.translatesAutoresizingMaskIntoConstraints = false
|
||||
hstack.orientation = .horizontal
|
||||
hstack.alignment = .centerY
|
||||
hstack.spacing = 8
|
||||
|
||||
container.addSubview(scroll)
|
||||
container.addSubview(hstack)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
scroll.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: hstack.topAnchor, constant: -8),
|
||||
|
||||
hstack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 4),
|
||||
hstack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -4),
|
||||
hstack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4)
|
||||
])
|
||||
|
||||
adoptTabContentSizing(container)
|
||||
item.view = container
|
||||
tabView.addTabViewItem(item)
|
||||
}
|
||||
|
||||
private func loadAgentCommandsIntoEditor() {
|
||||
let cmds = launchAgentManager.readCommands()
|
||||
agentCommandsTextView.string = cmds.joined(separator: "\n")
|
||||
}
|
||||
|
||||
@objc private func saveAgentCommands() {
|
||||
let raw = agentCommandsTextView.string
|
||||
let cmds = raw
|
||||
.components(separatedBy: .newlines)
|
||||
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||
.filter { !$0.isEmpty }
|
||||
launchAgentManager.writeCommands(cmds)
|
||||
}
|
||||
|
||||
@objc private func reloadAgent() {
|
||||
saveAgentCommands()
|
||||
launchAgentManager.resetAgent()
|
||||
}
|
||||
|
||||
private func makeTableContainer(table: NSTableView, columns: [(identifier: NSUserInterfaceItemIdentifier, title: String, width: CGFloat)], addAction: Selector, removeAction: Selector, saveAction: Selector) -> NSView {
|
||||
let scroll = NSScrollView()
|
||||
scroll.translatesAutoresizingMaskIntoConstraints = false
|
||||
scroll.hasVerticalScroller = true
|
||||
scroll.hasHorizontalScroller = true
|
||||
scroll.autohidesScrollers = true
|
||||
scroll.autoresizesSubviews = true
|
||||
scroll.contentView.copiesOnScroll = false
|
||||
|
||||
// Important: use frame-based layout inside NSScrollView's documentView to ensure visibility on all tabs
|
||||
table.translatesAutoresizingMaskIntoConstraints = true
|
||||
table.autoresizingMask = [.width, .height]
|
||||
table.headerView = NSTableHeaderView()
|
||||
table.usesAlternatingRowBackgroundColors = true
|
||||
table.allowsColumnReordering = false
|
||||
table.allowsColumnResizing = true
|
||||
table.usesAutomaticRowHeights = false
|
||||
table.rowHeight = 28
|
||||
table.intercellSpacing = NSSize(width: 4, height: 4)
|
||||
// Using sequential autoresizing prevents later columns from collapsing when the view first appears
|
||||
table.columnAutoresizingStyle = .sequentialColumnAutoresizingStyle
|
||||
table.delegate = self
|
||||
table.dataSource = self
|
||||
|
||||
// Columns
|
||||
for c in columns {
|
||||
let col = NSTableColumn(identifier: c.identifier)
|
||||
col.title = c.title
|
||||
// Allow user resizing while also participating in automatic resizing
|
||||
col.resizingMask = [.autoresizingMask, .userResizingMask]
|
||||
col.minWidth = max(80, c.width * 0.4)
|
||||
col.maxWidth = max(c.width * 2.0, col.minWidth + 40)
|
||||
col.width = c.width
|
||||
// Fit to header initially to ensure visibility even before rows render
|
||||
col.sizeToFit()
|
||||
// Enforce requested minimum character widths per tab/column
|
||||
if table === svcTable {
|
||||
if c.identifier.rawValue == "name" || c.identifier.rawValue == "host" {
|
||||
let minChars = widthForChars(32) + 12 // padding
|
||||
col.minWidth = max(col.minWidth, minChars)
|
||||
// equal starting widths for name and host; keep port smaller
|
||||
}
|
||||
} else if table === portTable {
|
||||
if c.identifier.rawValue == "service" {
|
||||
let minChars = widthForChars(32) + 12
|
||||
col.minWidth = max(col.minWidth, minChars)
|
||||
}
|
||||
}
|
||||
table.addTableColumn(col)
|
||||
}
|
||||
|
||||
// Set an initial frame and attach as documentView
|
||||
table.frame = NSRect(origin: .zero, size: NSSize(width: 800, height: 600))
|
||||
scroll.documentView = table
|
||||
|
||||
// Buttons
|
||||
let addButton = NSButton(title: "+", target: self, action: addAction)
|
||||
addButton.bezelStyle = .texturedRounded
|
||||
let removeButton = NSButton(title: "−", target: self, action: removeAction)
|
||||
removeButton.bezelStyle = .texturedRounded
|
||||
let saveButton = NSButton(title: "Save", target: self, action: saveAction)
|
||||
saveButton.bezelStyle = .rounded
|
||||
|
||||
let spacer = NSView()
|
||||
spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
spacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
let buttons = NSStackView(views: [addButton, removeButton, spacer, saveButton])
|
||||
buttons.orientation = .horizontal
|
||||
buttons.alignment = .centerY
|
||||
buttons.spacing = 8
|
||||
buttons.distribution = .fill
|
||||
buttons.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let container = NSView()
|
||||
// Very important: NSTabView expects its item.view to be frame-based. If we
|
||||
// disable translatesAutoresizingMaskIntoConstraints on this container, the
|
||||
// tab content can collapse to a tiny area. Keep it frame-based and let the
|
||||
// tab view drive its size via frame/autoresizing.
|
||||
container.translatesAutoresizingMaskIntoConstraints = true
|
||||
container.autoresizingMask = [.width, .height]
|
||||
container.addSubview(scroll)
|
||||
container.addSubview(buttons)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scroll.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
scroll.topAnchor.constraint(equalTo: container.topAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: buttons.topAnchor, constant: -8),
|
||||
|
||||
buttons.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
buttons.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
buttons.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
// Ensure a minimal height for the buttons bar so it doesn't collapse
|
||||
buttons.heightAnchor.constraint(greaterThanOrEqualToConstant: 32)
|
||||
])
|
||||
return container
|
||||
}
|
||||
|
||||
// MARK: - NSTabViewDelegate
|
||||
func tabView(_ tabView: NSTabView, didSelect tabViewItem: NSTabViewItem?) {
|
||||
// Refresh data when switching tabs to avoid stale/blank views
|
||||
kubeTable.reloadData()
|
||||
svcTable.reloadData()
|
||||
portTable.reloadData()
|
||||
// Force layout for the newly visible table to avoid zero-sized text fields
|
||||
if let id = tabViewItem?.identifier as? Int, let tab = Tab(rawValue: id) {
|
||||
// Ensure the selected tab's content view fills the tab content area
|
||||
if let v = tabViewItem?.view { adoptTabContentSizing(v) }
|
||||
switch tab {
|
||||
case .kubernetes: forceLayout(for: kubeTable)
|
||||
case .services: forceLayout(for: svcTable)
|
||||
case .ports: forceLayout(for: portTable)
|
||||
case .agentPF: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
@objc private func addKube() {
|
||||
kubeItems.append(.init(host: "", port: 6443))
|
||||
kubeTable.reloadData()
|
||||
}
|
||||
@objc private func removeKube() {
|
||||
let row = kubeTable.selectedRow
|
||||
if row >= 0 && row < kubeItems.count {
|
||||
kubeItems.remove(at: row)
|
||||
kubeTable.reloadData()
|
||||
}
|
||||
}
|
||||
@objc private func saveKube() {
|
||||
// Commit any in-progress text edits to our backing arrays
|
||||
window?.endEditing(for: nil)
|
||||
Config.shared.kubernetes = kubeItems
|
||||
Config.shared.save()
|
||||
}
|
||||
|
||||
@objc private func addService() {
|
||||
serviceItems.append(.init(name: "", host: "", port: 0))
|
||||
svcTable.reloadData()
|
||||
}
|
||||
@objc private func removeService() {
|
||||
let row = svcTable.selectedRow
|
||||
if row >= 0 && row < serviceItems.count {
|
||||
serviceItems.remove(at: row)
|
||||
svcTable.reloadData()
|
||||
}
|
||||
}
|
||||
@objc private func saveService() {
|
||||
// Commit any in-progress text edits to our backing arrays
|
||||
window?.endEditing(for: nil)
|
||||
Config.shared.services = serviceItems
|
||||
Config.shared.save()
|
||||
}
|
||||
|
||||
@objc private func addPort() {
|
||||
portItems.append(.init(id: "", namespace: "default", target: "svc/", address: "127.0.0.1", hostPort: "", servicePort: "", proto: "TCP", description: ""))
|
||||
portTable.reloadData()
|
||||
}
|
||||
@objc private func removePort() {
|
||||
let row = portTable.selectedRow
|
||||
if row >= 0 && row < portItems.count {
|
||||
portItems.remove(at: row)
|
||||
portTable.reloadData()
|
||||
}
|
||||
}
|
||||
@objc private func savePort() {
|
||||
// Commit any in-progress text edits to our backing arrays
|
||||
window?.endEditing(for: nil)
|
||||
do {
|
||||
try PortMappingsXMLStore.save(portItems)
|
||||
} catch {
|
||||
NSSound.beep()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - NSTableView
|
||||
func numberOfRows(in tableView: NSTableView) -> Int {
|
||||
if tableView === kubeTable { return kubeItems.count }
|
||||
if tableView === svcTable { return serviceItems.count }
|
||||
if tableView === portTable { return portItems.count }
|
||||
return 0
|
||||
}
|
||||
|
||||
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||
guard let tableColumn = tableColumn else { return nil }
|
||||
let colId = tableColumn.identifier
|
||||
|
||||
// Reuse a cell per column identifier
|
||||
let cell: NSTableCellView
|
||||
if let reused = tableView.makeView(withIdentifier: colId, owner: self) as? NSTableCellView {
|
||||
cell = reused
|
||||
} else {
|
||||
let newCell = NSTableCellView()
|
||||
newCell.identifier = colId
|
||||
let tf = NSTextField()
|
||||
tf.isBordered = true
|
||||
tf.isEditable = true
|
||||
tf.lineBreakMode = .byTruncatingTail
|
||||
tf.controlSize = .regular
|
||||
tf.font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
||||
tf.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
tf.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
tf.target = self
|
||||
tf.action = #selector(cellEdited(_:))
|
||||
tf.translatesAutoresizingMaskIntoConstraints = false
|
||||
newCell.addSubview(tf)
|
||||
newCell.textField = tf
|
||||
NSLayoutConstraint.activate([
|
||||
tf.leadingAnchor.constraint(equalTo: newCell.leadingAnchor, constant: 4),
|
||||
tf.trailingAnchor.constraint(equalTo: newCell.trailingAnchor, constant: -4),
|
||||
tf.topAnchor.constraint(equalTo: newCell.topAnchor, constant: 2),
|
||||
tf.bottomAnchor.constraint(equalTo: newCell.bottomAnchor, constant: -2)
|
||||
])
|
||||
cell = newCell
|
||||
}
|
||||
|
||||
guard let tf = cell.textField else { return cell }
|
||||
tf.tag = row
|
||||
|
||||
if tableView === kubeTable {
|
||||
let item = kubeItems[row]
|
||||
if colId.rawValue == "host" { tf.stringValue = item.host }
|
||||
else if colId.rawValue == "port" { tf.stringValue = String(item.port) }
|
||||
} else if tableView === svcTable {
|
||||
let item = serviceItems[row]
|
||||
if colId.rawValue == "name" { tf.stringValue = item.name }
|
||||
else if colId.rawValue == "host" { tf.stringValue = item.host }
|
||||
else if colId.rawValue == "port" { tf.stringValue = String(item.port) }
|
||||
} else if tableView === portTable {
|
||||
let item = portItems[row]
|
||||
if colId.rawValue == "service" { tf.stringValue = item.target }
|
||||
else if colId.rawValue == "namespace" { tf.stringValue = item.namespace }
|
||||
else if colId.rawValue == "expose" { tf.stringValue = item.hostPort }
|
||||
else if colId.rawValue == "internal" { tf.stringValue = item.servicePort }
|
||||
}
|
||||
return cell
|
||||
}
|
||||
|
||||
@objc private func cellEdited(_ sender: NSTextField) {
|
||||
// Find table and column from hierarchy
|
||||
var v: NSView? = sender
|
||||
var table: NSTableView?
|
||||
var cell: NSTableCellView?
|
||||
while let cur = v {
|
||||
if let tv = cur as? NSTableView { table = tv; break }
|
||||
if let c = cur as? NSTableCellView { cell = c }
|
||||
v = cur.superview
|
||||
}
|
||||
guard let tv = table, let cid = cell?.identifier?.rawValue else { return }
|
||||
let row = sender.tag
|
||||
let val = sender.stringValue
|
||||
if tv === kubeTable {
|
||||
if cid == "host" { kubeItems[row].host = val }
|
||||
else if cid == "port" { kubeItems[row].port = Int(val) ?? 6443 }
|
||||
} else if tv === svcTable {
|
||||
if cid == "name" { serviceItems[row].name = val }
|
||||
else if cid == "host" { serviceItems[row].host = val }
|
||||
else if cid == "port" { serviceItems[row].port = Int(val) ?? 0 }
|
||||
} else if tv === portTable {
|
||||
if cid == "service" { portItems[row].target = val }
|
||||
else if cid == "namespace" { portItems[row].namespace = val }
|
||||
else if cid == "expose" { portItems[row].hostPort = val }
|
||||
else if cid == "internal" { portItems[row].servicePort = val }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
@objc private func windowDidResize() {
|
||||
// Keep current tab content sized to the tab's content rect during window resizes
|
||||
syncCurrentTabContentFrame()
|
||||
// Re-layout the currently visible table for updated width
|
||||
if let id = tabView.selectedTabViewItem?.identifier as? Int, let tab = Tab(rawValue: id) {
|
||||
switch tab {
|
||||
case .kubernetes: sizeColumnsAndLayout(table: kubeTable)
|
||||
case .services: sizeColumnsAndLayout(table: svcTable)
|
||||
case .ports: sizeColumnsAndLayout(table: portTable)
|
||||
case .agentPF: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func adoptTabContentSizing(_ view: NSView) {
|
||||
view.translatesAutoresizingMaskIntoConstraints = true
|
||||
view.autoresizingMask = [.width, .height]
|
||||
view.frame = tabView.contentRect
|
||||
}
|
||||
|
||||
private func syncCurrentTabContentFrame() {
|
||||
if let v = tabView.selectedTabViewItem?.view { adoptTabContentSizing(v) }
|
||||
}
|
||||
|
||||
private func syncAllTabContentFrames() {
|
||||
for item in tabView.tabViewItems {
|
||||
if let v = item.view { adoptTabContentSizing(v) }
|
||||
}
|
||||
}
|
||||
private func sizeColumnsAndLayout(table: NSTableView) {
|
||||
// Defer sizing to when the table is in a window so we have real bounds
|
||||
DispatchQueue.main.async {
|
||||
guard let sv = table.enclosingScrollView else { return }
|
||||
// Expand the table to at least the visible area so columns can lay out
|
||||
let visible = sv.contentView.bounds.size
|
||||
var f = table.frame
|
||||
f.size.width = max(f.size.width, visible.width)
|
||||
f.size.height = max(f.size.height, visible.height)
|
||||
table.frame = f
|
||||
|
||||
// Distribute widths sensibly depending on which table this is
|
||||
self.distributeColumnWidths(for: table, availableWidth: visible.width)
|
||||
|
||||
table.noteNumberOfRowsChanged()
|
||||
self.forceLayout(for: table)
|
||||
}
|
||||
}
|
||||
|
||||
private func forceLayout(for table: NSTableView) {
|
||||
// Ensure the scroll view and its content lay out now; dispatch to next runloop
|
||||
DispatchQueue.main.async {
|
||||
table.enclosingScrollView?.tile()
|
||||
table.layoutSubtreeIfNeeded()
|
||||
table.enclosingScrollView?.layoutSubtreeIfNeeded()
|
||||
table.headerView?.layoutSubtreeIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
// Distribute columns per table type to avoid first-column-only syndrome when appearing
|
||||
private func distributeColumnWidths(for table: NSTableView, availableWidth: CGFloat) {
|
||||
guard availableWidth.isFinite, availableWidth > 0 else { return }
|
||||
let cols = table.tableColumns
|
||||
if cols.isEmpty { return }
|
||||
|
||||
// Side paddings inside container are ~0 here; keep a small safety margin
|
||||
let padding: CGFloat = 8
|
||||
let width = max(100, availableWidth - padding)
|
||||
|
||||
func apply(_ fractions: [CGFloat]) {
|
||||
// Normalize fractions
|
||||
let sum = fractions.reduce(0, +)
|
||||
let norm = sum > 0 ? fractions.map { $0 / sum } : Array(repeating: 1.0 / CGFloat(fractions.count), count: fractions.count)
|
||||
for (i, col) in cols.enumerated() {
|
||||
let frac = i < norm.count ? norm[i] : (1.0 / CGFloat(cols.count))
|
||||
let target = max(col.minWidth, min(col.maxWidth, width * frac))
|
||||
col.width = target
|
||||
}
|
||||
}
|
||||
|
||||
if table === kubeTable {
|
||||
// host, port
|
||||
apply([0.75, 0.25])
|
||||
} else if table === svcTable {
|
||||
// name, host, port — make name and host same size by default
|
||||
apply([0.41, 0.41, 0.18])
|
||||
} else if table === portTable {
|
||||
// service, namespace, expose, internal
|
||||
apply([0.42, 0.26, 0.16, 0.16])
|
||||
} else {
|
||||
// Fallback equal distribution
|
||||
apply(Array(repeating: 1.0, count: cols.count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Character width utilities
|
||||
private extension PreferencesWindowController {
|
||||
/// Approximate pixel width for N characters using the table cell font
|
||||
func widthForChars(_ count: Int) -> CGFloat {
|
||||
let chars = max(0, count)
|
||||
if chars == 0 { return 0 }
|
||||
// Use a wide glyph to avoid underestimating; system font to match cells
|
||||
let sample = String(repeating: "W", count: chars) as NSString
|
||||
let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)
|
||||
let size = sample.size(withAttributes: [.font: font])
|
||||
return ceil(size.width)
|
||||
}
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum ProleEnv {
|
||||
static func proleHome() -> URL {
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
let home = env["HOME"] ?? NSHomeDirectory()
|
||||
let proleHome = env["PROLE_HOME"] ?? home
|
||||
return URL(fileURLWithPath: proleHome, isDirectory: true)
|
||||
}
|
||||
|
||||
static func logsDir() -> URL {
|
||||
return proleHome().appendingPathComponent("logs", isDirectory: true)
|
||||
}
|
||||
|
||||
static func confDir() -> URL {
|
||||
return proleHome().appendingPathComponent("conf", isDirectory: true)
|
||||
}
|
||||
|
||||
static func bootstrap() {
|
||||
let fm = FileManager.default
|
||||
// Ensure base, logs, conf
|
||||
for dir in [proleHome(), logsDir(), confDir()] {
|
||||
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
}
|
||||
// Ensure conf/port-mapping.cfg exists
|
||||
let pm = confDir().appendingPathComponent("port-mapping.cfg")
|
||||
if !fm.fileExists(atPath: pm.path) {
|
||||
let tpl = """
|
||||
# Port mappings for Prole Tools (read by scripts).
|
||||
# Format examples:
|
||||
# grafana: local=3000 remote=80 ns=monitoring svc=kps-grafana address=0.0.0.0
|
||||
# db: local=5432 remote=5432 ns=default svc=prole-db-rw address=0.0.0.0
|
||||
|
||||
# Add your mappings below. One mapping per line as key=value tokens.
|
||||
"""
|
||||
try? tpl.trimmingCharacters(in: .whitespacesAndNewlines).appending("\n").write(to: pm, atomically: true, encoding: .utf8)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,249 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
import Network
|
||||
|
||||
final class ServiceChecker {
|
||||
static let statusDidChangeNotification = Notification.Name("ServiceChecker.statusDidChange")
|
||||
static let forceRefreshNotification = Notification.Name("ServiceChecker.forceRefresh")
|
||||
|
||||
private let queue = DispatchQueue(label: "prole.status.checker")
|
||||
private var timer: DispatchSourceTimer?
|
||||
private var isRunning: Bool = false
|
||||
private var lastRefreshAt: Date = .distantPast
|
||||
private let refreshInterval: TimeInterval = 30
|
||||
|
||||
// Public reachability flags
|
||||
private(set) var k3dReachable = false
|
||||
private(set) var prometheusReachable = false
|
||||
private(set) var grafanaReachable = false
|
||||
private(set) var openbaoReachable = false
|
||||
private(set) var ollamaReachable = false
|
||||
private(set) var postgresReachable = false
|
||||
|
||||
// Last error messages (for tooltips when red)
|
||||
private(set) var k3dError: String? = nil
|
||||
private(set) var prometheusError: String? = nil
|
||||
private(set) var grafanaError: String? = nil
|
||||
private(set) var openbaoError: String? = nil
|
||||
private(set) var ollamaError: String? = nil
|
||||
private(set) var postgresError: String? = nil
|
||||
|
||||
// Generic per-endpoint state so UI can query dynamically from Preferences
|
||||
struct EndpointState: Equatable {
|
||||
var reachable: Bool
|
||||
var latencyMs: Int
|
||||
var error: String?
|
||||
var lastChecked: Date
|
||||
}
|
||||
|
||||
// Cached states (by logical keys)
|
||||
// services: key = service name (Config.ServiceEndpoint.name)
|
||||
// kubes: key = host:port string
|
||||
private(set) var serviceStates: [String: EndpointState] = [:]
|
||||
private(set) var kubeStates: [String: EndpointState] = [:]
|
||||
|
||||
init() {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(forceRefresh), name: Self.forceRefreshNotification, object: nil)
|
||||
// When configuration changes, invalidate cache and reload immediately
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(configDidChange), name: Config.didChangeNotification, object: nil)
|
||||
}
|
||||
|
||||
func start(interval: TimeInterval = 30) {
|
||||
timer?.cancel()
|
||||
let t = DispatchSource.makeTimerSource(queue: queue)
|
||||
t.schedule(deadline: .now(), repeating: interval)
|
||||
t.setEventHandler { [weak self] in self?.refreshAll() }
|
||||
dlog("ServiceChecker: starting timer, interval=\(interval)s")
|
||||
t.resume()
|
||||
timer = t
|
||||
}
|
||||
|
||||
@objc private func forceRefresh() { queue.async { self.refreshAll() } }
|
||||
|
||||
@objc private func configDidChange() {
|
||||
queue.async {
|
||||
// Invalidate freshness so the next refresh runs immediately
|
||||
self.lastRefreshAt = .distantPast
|
||||
// Clear caches so UI doesn't briefly show stale dynamic entries
|
||||
self.serviceStates.removeAll()
|
||||
self.kubeStates.removeAll()
|
||||
self.refreshAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshAll() {
|
||||
// Ensure we don't overlap and we don't rerun faster than every 30s
|
||||
if isRunning {
|
||||
dlog("ServiceChecker: skip — previous refresh still running")
|
||||
return
|
||||
}
|
||||
let now = Date()
|
||||
if now.timeIntervalSince(lastRefreshAt) < refreshInterval {
|
||||
dlog("ServiceChecker: skip — cache still fresh (< \(Int(refreshInterval))s)")
|
||||
return
|
||||
}
|
||||
isRunning = true
|
||||
dlog("ServiceChecker: begin refresh round")
|
||||
let group = DispatchGroup()
|
||||
|
||||
// clear previous errors before a new round (legacy fields)
|
||||
k3dError = nil; prometheusError = nil; grafanaError = nil; openbaoError = nil; ollamaError = nil; postgresError = nil
|
||||
|
||||
let cfg = Config.shared
|
||||
// Iterate Services from Preferences
|
||||
for svc in cfg.services {
|
||||
group.enter()
|
||||
|
||||
let nameUpper = svc.name.uppercased()
|
||||
if nameUpper == "OPENBAO" {
|
||||
// Special check for OpenBAO via kubectl if local
|
||||
// We use a global utility queue for the script bridge to avoid blocking
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let res = PFScriptBridge.openbaoStatus()
|
||||
let isReady = res.out.contains("true")
|
||||
let errorMsg = isReady ? nil : (res.out.isEmpty ? "No openbao pods found" : "OpenBAO not ready: \(res.out)")
|
||||
|
||||
self.queue.async {
|
||||
let st = EndpointState(reachable: isReady, latencyMs: 0, error: errorMsg, lastChecked: Date())
|
||||
self.serviceStates[svc.name] = st
|
||||
self.openbaoReachable = isReady
|
||||
self.openbaoError = errorMsg
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
tcpPing(host: svc.host, port: UInt16(svc.port)) { [weak self] ok, ms, err in
|
||||
guard let self = self else { group.leave(); return }
|
||||
let key = svc.name
|
||||
let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date())
|
||||
self.serviceStates[key] = st
|
||||
|
||||
// Update specific flags
|
||||
switch nameUpper {
|
||||
case "K3D":
|
||||
self.k3dReachable = ok
|
||||
self.k3dError = err
|
||||
case "PROMETHEUS":
|
||||
self.prometheusReachable = ok
|
||||
self.prometheusError = err
|
||||
case "GRAFANA":
|
||||
self.grafanaReachable = ok
|
||||
self.grafanaError = err
|
||||
case "OLLAMA":
|
||||
self.ollamaReachable = ok
|
||||
self.ollamaError = err
|
||||
case "POSTGRESQL":
|
||||
self.postgresReachable = ok
|
||||
self.postgresError = err
|
||||
default:
|
||||
break
|
||||
}
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate Kubernetes endpoints from Preferences
|
||||
let kubes = cfg.kubernetes
|
||||
for (idx, k) in kubes.enumerated() {
|
||||
group.enter()
|
||||
tcpPing(host: k.host, port: UInt16(k.port)) { [weak self] ok, ms, err in
|
||||
guard let self = self else { group.leave(); return }
|
||||
let key = "\(k.host):\(k.port)"
|
||||
let st = EndpointState(reachable: ok, latencyMs: ms, error: err, lastChecked: Date())
|
||||
self.kubeStates[key] = st
|
||||
group.leave()
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
self.lastRefreshAt = Date()
|
||||
self.isRunning = false
|
||||
dlog("ServiceChecker: refresh round complete → posting statusDidChangeNotification")
|
||||
NotificationCenter.default.post(name: Self.statusDidChangeNotification, object: self)
|
||||
}
|
||||
}
|
||||
|
||||
private func tcpPing(host: String, port: UInt16, timeout: TimeInterval = 2.0, completion: @escaping (Bool, Int, String?) -> Void) {
|
||||
dlog("tcpPing: attempting \(host):\(port) timeout=\(timeout)s")
|
||||
let start = DispatchTime.now()
|
||||
let params = NWParameters.tcp
|
||||
params.allowLocalEndpointReuse = true
|
||||
let endpoint = NWEndpoint.hostPort(host: .name(host, nil), port: .init(integerLiteral: port))
|
||||
let conn = NWConnection(to: endpoint, using: params)
|
||||
|
||||
// Ensure completion is invoked exactly once
|
||||
var finished = false
|
||||
func finishOnce(_ ok: Bool, _ ms: Int, _ err: String?, reason: String) {
|
||||
// All state updates and timeout run on `queue` so this is serialized
|
||||
if finished { return }
|
||||
finished = true
|
||||
dlog("tcpPing: finishOnce(\(host):\(port)) reason=\(reason) ok=\(ok) ms=\(ms) err=\(err ?? "nil")")
|
||||
completion(ok, ms, err)
|
||||
}
|
||||
|
||||
// Prepare timeout work item so we can cancel it on success/failure
|
||||
let timeoutWork = DispatchWorkItem { [weak conn] in
|
||||
if finished { return }
|
||||
dlog("tcpPing: timeout reached for \(host):\(port); cancelling connection")
|
||||
conn?.cancel()
|
||||
finishOnce(false, -1, "connection timed out", reason: "timeout")
|
||||
}
|
||||
|
||||
conn.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
let elapsed = DispatchTime.now().uptimeNanoseconds - start.uptimeNanoseconds
|
||||
let ms = Int(Double(elapsed) / 1_000_000.0)
|
||||
dlog("tcpPing: READY \(host):\(port) in \(ms) ms")
|
||||
timeoutWork.cancel()
|
||||
finishOnce(true, ms, nil, reason: "ready")
|
||||
conn.cancel() // will emit .cancelled; ignored due to finished=true
|
||||
case .failed(let error):
|
||||
let msg = Self.describeNWError(error)
|
||||
dlog("tcpPing: FAILED \(host):\(port) — \(msg)")
|
||||
timeoutWork.cancel()
|
||||
finishOnce(false, -1, msg, reason: "failed")
|
||||
conn.cancel()
|
||||
case .cancelled:
|
||||
// If we already finished (e.g., due to .ready), this is expected; ignore.
|
||||
if finished {
|
||||
dlog("tcpPing: CANCELLED \(host):\(port) after finish — ignoring")
|
||||
} else {
|
||||
// Cancel without prior .ready/.failed implies timeout or external cancel
|
||||
finishOnce(false, -1, "connection cancelled (possible timeout)", reason: "cancelled-before-finish")
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
conn.start(queue: queue)
|
||||
// Schedule timeout on the same queue
|
||||
queue.asyncAfter(deadline: .now() + timeout, execute: timeoutWork)
|
||||
}
|
||||
|
||||
// Tooltips
|
||||
func tooltipFor(name: String) -> String {
|
||||
let st = serviceStates[name]
|
||||
let reachable = st?.reachable ?? false
|
||||
let error = st?.error
|
||||
let latency = st?.latencyMs ?? -1
|
||||
let host = Config.shared.services.first(where: { $0.name == name })?.host ?? "unknown"
|
||||
let port = Config.shared.services.first(where: { $0.name == name })?.port ?? 0
|
||||
|
||||
if reachable { return "\(name) (\(host):\(port)) — reachable (\(latency) ms)" }
|
||||
var s = "\(name) (\(host):\(port)) — unreachable"
|
||||
if let e = error { s += "\nError: \(e)" }
|
||||
return s
|
||||
}
|
||||
|
||||
private static func describeNWError(_ error: NWError) -> String {
|
||||
switch error {
|
||||
case .posix(let code): return "POSIX \(code.rawValue): \(code)"
|
||||
case .dns(let code): return "DNS \(code): \(code)"
|
||||
case .tls(let status): return "TLS/OSStatus \(status)"
|
||||
case .wifiAware(let reason): return "Wi-Fi Aware: \(reason)"
|
||||
@unknown default: return "Unknown network error"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,133 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
final class StatusItemController {
|
||||
struct Actions {
|
||||
let onLeftClick: () -> Void
|
||||
let onToggleStatusBar: () -> Void
|
||||
let onToggleAppWindow: () -> Void
|
||||
let onRefresh: () -> Void
|
||||
let onQuit: () -> Void
|
||||
}
|
||||
|
||||
private var statusItem: NSStatusItem?
|
||||
private let actions: Actions
|
||||
private var stateStatusBarVisible: Bool = true
|
||||
private var stateAppWindowVisible: Bool = false
|
||||
|
||||
init(actions: Actions) {
|
||||
self.actions = actions
|
||||
createStatusItemIfNeeded()
|
||||
}
|
||||
|
||||
func setState(statusBarVisible: Bool, appWindowVisible: Bool) {
|
||||
self.stateStatusBarVisible = statusBarVisible
|
||||
self.stateAppWindowVisible = appWindowVisible
|
||||
}
|
||||
|
||||
func hideStatusItem() {
|
||||
if let item = statusItem {
|
||||
NSStatusBar.system.removeStatusItem(item)
|
||||
statusItem = nil
|
||||
}
|
||||
}
|
||||
|
||||
func showStatusItem() {
|
||||
createStatusItemIfNeeded()
|
||||
}
|
||||
|
||||
var isVisible: Bool { statusItem != nil }
|
||||
|
||||
private func createStatusItemIfNeeded() {
|
||||
guard statusItem == nil else { return }
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
statusItem = item
|
||||
if let button = item.button {
|
||||
// Use a proper template image so it always tints/appears in light/dark menu bar
|
||||
button.image = StatusItemController.makeTemplateGlyphImage("P")
|
||||
button.imagePosition = .imageOnly
|
||||
button.target = self
|
||||
button.action = #selector(handleClick)
|
||||
// Only respond to left click; right click intentionally does nothing
|
||||
button.sendAction(on: [.leftMouseUp])
|
||||
button.toolTip = "Prole Tools — Click for menu"
|
||||
button.appearsDisabled = false
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleClick(_ sender: Any?) {
|
||||
// Always open the context menu on left click
|
||||
showMenu()
|
||||
}
|
||||
|
||||
private func showMenu() {
|
||||
guard let button = statusItem?.button else { return }
|
||||
let menu = NSMenu(title: "ProleStatus")
|
||||
|
||||
let statusBarTitle = stateStatusBarVisible ? "Hide Status Bar Icon" : "Show Status Bar Icon"
|
||||
let appWindowTitle = stateAppWindowVisible ? "Hide Main Window" : "Show Main Window"
|
||||
|
||||
let sbItem = NSMenuItem(title: statusBarTitle, action: #selector(toggleStatusBar), keyEquivalent: "")
|
||||
sbItem.target = self
|
||||
menu.addItem(sbItem)
|
||||
|
||||
let winItem = NSMenuItem(title: appWindowTitle, action: #selector(toggleAppWindow), keyEquivalent: "")
|
||||
winItem.target = self
|
||||
menu.addItem(winItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let refreshItem = NSMenuItem(title: "Refresh Now", action: #selector(refreshNow), keyEquivalent: "r")
|
||||
refreshItem.target = self
|
||||
menu.addItem(refreshItem)
|
||||
|
||||
menu.addItem(.separator())
|
||||
|
||||
let quitItem = NSMenuItem(title: "Quit Prole Tools", action: #selector(quitApp), keyEquivalent: "q")
|
||||
quitItem.target = self
|
||||
menu.addItem(quitItem)
|
||||
|
||||
let point = NSPoint(x: 0, y: button.bounds.minY - 3)
|
||||
menu.popUp(positioning: nil, at: point, in: button)
|
||||
}
|
||||
|
||||
@objc private func toggleStatusBar() { actions.onToggleStatusBar() }
|
||||
@objc private func toggleAppWindow() { actions.onToggleAppWindow() }
|
||||
@objc private func refreshNow() { actions.onRefresh() }
|
||||
@objc private func quitApp() { actions.onQuit() }
|
||||
}
|
||||
|
||||
// MARK: - Icon drawing
|
||||
extension StatusItemController {
|
||||
/// Create a monochrome template image from a single-character glyph to use as a status bar icon.
|
||||
/// The image is marked as template so macOS tints it appropriately.
|
||||
fileprivate static func makeTemplateGlyphImage(_ char: Character) -> NSImage? {
|
||||
let size = NSSize(width: 18, height: 18)
|
||||
let image = NSImage(size: size)
|
||||
image.lockFocus()
|
||||
defer { image.unlockFocus() }
|
||||
|
||||
// Clear background (transparent)
|
||||
NSColor.clear.set()
|
||||
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
|
||||
|
||||
// Draw the glyph centered
|
||||
let string = String(char)
|
||||
let font = NSFont.monospacedSystemFont(ofSize: 14, weight: .bold)
|
||||
let attrs: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.foregroundColor: NSColor.black // template ignores color; use black base
|
||||
]
|
||||
let attributed = NSAttributedString(string: string, attributes: attrs)
|
||||
let textSize = attributed.size()
|
||||
let rect = NSRect(
|
||||
x: (size.width - textSize.width) / 2.0,
|
||||
y: (size.height - textSize.height) / 2.0,
|
||||
width: textSize.width,
|
||||
height: textSize.height
|
||||
)
|
||||
attributed.draw(in: rect)
|
||||
|
||||
image.isTemplate = true
|
||||
return image
|
||||
}
|
||||
}
|
||||
@ -1,352 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
final class StatusView: NSView {
|
||||
private let light1 = TrafficLight()
|
||||
private let light2 = TrafficLight()
|
||||
private let light3 = TrafficLight()
|
||||
private let light4 = TrafficLight()
|
||||
private let light5 = TrafficLight()
|
||||
private let light6 = TrafficLight()
|
||||
private let marquee = MarqueeView()
|
||||
private let maximizeButton: NSButton = {
|
||||
let b = NSButton(title: "□", target: nil, action: nil)
|
||||
b.bezelStyle = .texturedRounded
|
||||
b.setButtonType(.momentaryPushIn)
|
||||
b.toolTip = "Show Main Window"
|
||||
b.isEnabled = true
|
||||
b.refusesFirstResponder = true // Don't take focus away from other apps
|
||||
b.setContentHuggingPriority(.required, for: .horizontal)
|
||||
b.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
return b
|
||||
}()
|
||||
|
||||
private var checker: ServiceChecker?
|
||||
private let timeFormatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.locale = .current
|
||||
df.timeZone = .current
|
||||
// Include full date and timezone offset (e.g., 2025-11-17 20:31:05 -08:00)
|
||||
df.dateFormat = "yyyy-MM-dd HH:mm:ss ZZZZZ"
|
||||
return df
|
||||
}()
|
||||
private let fullFormatter: DateFormatter = {
|
||||
let df = DateFormatter()
|
||||
df.locale = .current
|
||||
df.timeZone = .current
|
||||
df.dateStyle = .medium
|
||||
df.timeStyle = .medium
|
||||
return df
|
||||
}()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let stack = NSStackView(views: [light1, light2, light3, light4, light5, light6, marquee, maximizeButton])
|
||||
stack.orientation = .horizontal
|
||||
stack.alignment = .centerY
|
||||
stack.distribution = .equalSpacing
|
||||
stack.spacing = 12
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
addSubview(stack)
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: centerYAnchor)
|
||||
])
|
||||
|
||||
// Wire button actions
|
||||
maximizeButton.target = self
|
||||
maximizeButton.action = #selector(didTapMaximize)
|
||||
|
||||
// Tracking for hover tooltips
|
||||
addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], owner: self, userInfo: nil))
|
||||
|
||||
// Configure marquee width to be ~64 monospace characters
|
||||
configureMarqueeWidth()
|
||||
|
||||
// Ensure button responds to mouse down immediately for better responsiveness in overlays
|
||||
maximizeButton.sendAction(on: [.leftMouseDown, .leftMouseUp])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? {
|
||||
// Allow interaction with the maximize button.
|
||||
let pointInButton = convert(point, to: maximizeButton)
|
||||
if maximizeButton.bounds.contains(pointInButton) {
|
||||
return maximizeButton
|
||||
}
|
||||
|
||||
// Allow interaction with traffic lights for tooltips
|
||||
for light in [light1, light2, light3, light4, light5, light6] {
|
||||
let pointInLight = convert(point, to: light)
|
||||
if light.bounds.contains(pointInLight) {
|
||||
return light
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindTo(serviceChecker: ServiceChecker) {
|
||||
self.checker = serviceChecker
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(updateLights), name: ServiceChecker.statusDidChangeNotification, object: serviceChecker)
|
||||
updateLights()
|
||||
}
|
||||
|
||||
@objc private func updateLights() {
|
||||
guard let c = checker else { return }
|
||||
let services = Config.shared.services
|
||||
|
||||
// Light 1: K3D
|
||||
if let s = services.first(where: { $0.name.uppercased() == "K3D" }) {
|
||||
let state = c.serviceStates[s.name]
|
||||
light1.state = (state?.reachable ?? false) ? .green : .red
|
||||
light1.toolTip = c.tooltipFor(name: s.name)
|
||||
}
|
||||
|
||||
// Light 2: Prometheus
|
||||
if let s = services.first(where: { $0.name.uppercased() == "PROMETHEUS" }) {
|
||||
let state = c.serviceStates[s.name]
|
||||
light2.state = (state?.reachable ?? false) ? .green : .red
|
||||
light2.toolTip = c.tooltipFor(name: s.name)
|
||||
}
|
||||
|
||||
// Light 3: Grafana
|
||||
if let s = services.first(where: { $0.name.uppercased() == "GRAFANA" }) {
|
||||
let state = c.serviceStates[s.name]
|
||||
light3.state = (state?.reachable ?? false) ? .green : .red
|
||||
light3.toolTip = c.tooltipFor(name: s.name)
|
||||
}
|
||||
|
||||
// Light 4: OpenBAO
|
||||
if let s = services.first(where: { $0.name.uppercased() == "OPENBAO" }) {
|
||||
let state = c.serviceStates[s.name]
|
||||
light4.state = (state?.reachable ?? false) ? .green : .red
|
||||
light4.toolTip = c.tooltipFor(name: s.name)
|
||||
}
|
||||
|
||||
// Light 5: Ollama
|
||||
if let s = services.first(where: { $0.name.uppercased() == "OLLAMA" }) {
|
||||
let state = c.serviceStates[s.name]
|
||||
light5.state = (state?.reachable ?? false) ? .green : .red
|
||||
light5.toolTip = c.tooltipFor(name: s.name)
|
||||
}
|
||||
|
||||
// Light 6: PostgreSQL
|
||||
if let s = services.first(where: { $0.name.uppercased() == "POSTGRESQL" }) {
|
||||
let state = c.serviceStates[s.name]
|
||||
light6.state = (state?.reachable ?? false) ? .green : .red
|
||||
light6.toolTip = c.tooltipFor(name: s.name)
|
||||
}
|
||||
|
||||
// Update marquee text with status summary and ensure it scrolls
|
||||
// For the scrolling status, use new contents: scroll the output of kubectl cnpg status | head -10
|
||||
DispatchQueue.global(qos: .utility).async {
|
||||
let res = PFScriptBridge.cnpgStatus()
|
||||
let msg = res.out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "Waiting for PostgreSQL status..." : res.out.replacingOccurrences(of: "\n", with: " • ")
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.marquee.setText(msg)
|
||||
self?.marquee.toolTip = msg
|
||||
self?.needsDisplay = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Button actions
|
||||
@objc private func didTapMaximize() {
|
||||
dlog("StatusView: didTapMaximize button clicked")
|
||||
// Explicitly request the main window to show (don’t toggle modes implicitly)
|
||||
NotificationCenter.default.post(name: .showMainWindow, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - MarqueeView
|
||||
final class MarqueeView: NSView {
|
||||
private let scrollClip = NSView()
|
||||
private let label1 = NSTextField(labelWithString: "")
|
||||
private let label2 = NSTextField(labelWithString: "")
|
||||
private var timer: Timer?
|
||||
// Slow down by one third: 60 → 40 pts/sec
|
||||
private var speedPointsPerSec: CGFloat = 40.0 // horizontal speed
|
||||
private var lastTick: TimeInterval = CACurrentMediaTime()
|
||||
private var currentText: String = ""
|
||||
|
||||
// Public width constraint can be set by container; we keep hugging low so it expands to fixed width
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
translatesAutoresizingMaskIntoConstraints = false
|
||||
wantsLayer = false
|
||||
|
||||
scrollClip.wantsLayer = false
|
||||
scrollClip.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(scrollClip)
|
||||
|
||||
// Configure labels
|
||||
for lbl in [label1, label2] {
|
||||
lbl.font = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)
|
||||
lbl.textColor = .secondaryLabelColor
|
||||
lbl.alignment = .left
|
||||
lbl.backgroundColor = .clear
|
||||
lbl.isBezeled = false
|
||||
lbl.drawsBackground = false
|
||||
lbl.lineBreakMode = .byClipping
|
||||
lbl.translatesAutoresizingMaskIntoConstraints = true // we will manage frames manually
|
||||
}
|
||||
|
||||
scrollClip.addSubview(label1)
|
||||
scrollClip.addSubview(label2)
|
||||
|
||||
// Clip to bounds so text scrolls inside
|
||||
wantsLayer = true
|
||||
layer?.masksToBounds = true
|
||||
|
||||
setContentHuggingPriority(.defaultLow, for: .horizontal)
|
||||
setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
scrollClip.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
scrollClip.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
scrollClip.topAnchor.constraint(equalTo: topAnchor),
|
||||
scrollClip.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
start()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
||||
|
||||
override func viewDidMoveToWindow() {
|
||||
super.viewDidMoveToWindow()
|
||||
if window != nil { start() } else { stop() }
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
layoutLabelsIfNeeded()
|
||||
}
|
||||
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil } // click-through
|
||||
|
||||
func setText(_ text: String) {
|
||||
if text == currentText { return }
|
||||
currentText = text
|
||||
label1.stringValue = text + String(repeating: " ", count: 8)
|
||||
label2.stringValue = label1.stringValue
|
||||
layoutLabelsIfNeeded(resetOffset: true)
|
||||
}
|
||||
|
||||
private func layoutLabelsIfNeeded(resetOffset: Bool = false) {
|
||||
let h = bounds.height
|
||||
let y = (h - intrinsicLineHeight())/2.0
|
||||
let size = label1.intrinsicContentSize
|
||||
let w = size.width
|
||||
// Place labels back-to-back for seamless loop
|
||||
if resetOffset {
|
||||
label1.frame = NSRect(x: 0, y: y, width: w, height: size.height)
|
||||
label2.frame = NSRect(x: w, y: y, width: w, height: size.height)
|
||||
} else if label1.frame.size.width == 0 || label2.frame.size.width == 0 {
|
||||
label1.frame = NSRect(x: label1.frame.origin.x, y: y, width: w, height: size.height)
|
||||
label2.frame = NSRect(x: label2.frame.origin.x, y: y, width: w, height: size.height)
|
||||
} else {
|
||||
// Keep vertically centered
|
||||
label1.frame.origin.y = y
|
||||
label2.frame.origin.y = y
|
||||
}
|
||||
}
|
||||
|
||||
private func intrinsicLineHeight() -> CGFloat {
|
||||
let f = label1.font ?? NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)
|
||||
return ceil(f.ascender - f.descender)
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
let now = CACurrentMediaTime()
|
||||
let dt = now - lastTick
|
||||
lastTick = now
|
||||
let dx = CGFloat(dt) * speedPointsPerSec
|
||||
|
||||
// Move both labels left by dx
|
||||
label1.frame.origin.x -= dx
|
||||
label2.frame.origin.x -= dx
|
||||
|
||||
// When a label fully leaves on the left, move it to the right of the other
|
||||
if label1.frame.maxX <= 0 {
|
||||
label1.frame.origin.x = label2.frame.maxX
|
||||
}
|
||||
if label2.frame.maxX <= 0 {
|
||||
label2.frame.origin.x = label1.frame.maxX
|
||||
}
|
||||
}
|
||||
|
||||
func start() {
|
||||
stop()
|
||||
lastTick = CACurrentMediaTime()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0/60.0, repeats: true) { [weak self] _ in
|
||||
self?.tick()
|
||||
}
|
||||
RunLoop.main.add(timer!, forMode: .common)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
deinit { stop() }
|
||||
}
|
||||
|
||||
// Utility to compute width for N monospace characters
|
||||
private func widthForMonospaceCharacters(_ count: Int, font: NSFont) -> CGFloat {
|
||||
let sample = String(repeating: "0", count: max(1, count))
|
||||
let attrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||
let w = (sample as NSString).size(withAttributes: attrs).width
|
||||
return ceil(w)
|
||||
}
|
||||
|
||||
private extension StatusView {
|
||||
func configureMarqueeWidth() {
|
||||
// Match font with timestamp for visual cohesion
|
||||
let font = NSFont.monospacedSystemFont(ofSize: 10, weight: .regular)
|
||||
marquee.heightAnchor.constraint(equalToConstant: ceil(font.capHeight * 1.8)).isActive = true
|
||||
let width = widthForMonospaceCharacters(64, font: font)
|
||||
let wConstraint = marquee.widthAnchor.constraint(equalToConstant: width)
|
||||
wConstraint.priority = .required
|
||||
wConstraint.isActive = true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Notification bridge
|
||||
extension Notification.Name {
|
||||
static let showMainWindow = Notification.Name("ProleStatus.showMainWindow")
|
||||
static let toggleMode = Notification.Name("ProleStatus.toggleMode")
|
||||
}
|
||||
|
||||
final class TrafficLight: NSView {
|
||||
enum State { case green, yellow, red }
|
||||
var state: State = .red { didSet { needsDisplay = true } }
|
||||
|
||||
override var intrinsicContentSize: NSSize { NSSize(width: 14, height: 14) }
|
||||
|
||||
override func draw(_ dirtyRect: NSRect) {
|
||||
super.draw(dirtyRect)
|
||||
let rect = bounds.insetBy(dx: 1, dy: 1)
|
||||
let path = NSBezierPath(ovalIn: rect)
|
||||
let color: NSColor
|
||||
switch state {
|
||||
case .green: color = NSColor.systemGreen
|
||||
case .yellow: color = NSColor.systemYellow
|
||||
case .red: color = NSColor.systemRed
|
||||
}
|
||||
color.setFill()
|
||||
path.fill()
|
||||
|
||||
// subtle ring for better contrast against menu material
|
||||
NSColor.black.withAlphaComponent(0.12).setStroke()
|
||||
path.lineWidth = 1
|
||||
path.stroke()
|
||||
}
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
import AppKit
|
||||
|
||||
_ = NSApplication.shared
|
||||
let delegate = AppDelegate()
|
||||
dlog("Process started. Setting application delegate and running app loop…")
|
||||
NSApplication.shared.delegate = delegate
|
||||
NSApplication.shared.run()
|
||||
@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AvailableLibraries</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>BinaryPath</key>
|
||||
<string>RoyalVNCKit.framework/Versions/A/RoyalVNCKit</string>
|
||||
<key>LibraryIdentifier</key>
|
||||
<string>macos-arm64</string>
|
||||
<key>LibraryPath</key>
|
||||
<string>RoyalVNCKit.framework</string>
|
||||
<key>SupportedArchitectures</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>SupportedPlatform</key>
|
||||
<string>macos</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>XFWK</string>
|
||||
<key>XCFrameworkFormatVersion</key>
|
||||
<string>1.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildMachineOSBuild</key>
|
||||
<string>25B78</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>RoyalVNCKit</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>royalvnc-1.0.1.RoyalVNCKit</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>RoyalVNCKit</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>25B74</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>26.1</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>25B74</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx26.1</string>
|
||||
<key>DTXcode</key>
|
||||
<string>2611</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>17B100</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildMachineOSBuild</key>
|
||||
<string>25B78</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>RoyalVNCKit</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>royalvnc-1.0.1.RoyalVNCKit</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>RoyalVNCKit</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>25B74</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>26.1</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>25B74</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx26.1</string>
|
||||
<key>DTXcode</key>
|
||||
<string>2611</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>17B100</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@ -1,46 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>BuildMachineOSBuild</key>
|
||||
<string>25B78</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>RoyalVNCKit</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>royalvnc-1.0.1.RoyalVNCKit</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>RoyalVNCKit</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>FMWK</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleSupportedPlatforms</key>
|
||||
<array>
|
||||
<string>MacOSX</string>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>DTCompiler</key>
|
||||
<string>com.apple.compilers.llvm.clang.1_0</string>
|
||||
<key>DTPlatformBuild</key>
|
||||
<string>25B74</string>
|
||||
<key>DTPlatformName</key>
|
||||
<string>macosx</string>
|
||||
<key>DTPlatformVersion</key>
|
||||
<string>26.1</string>
|
||||
<key>DTSDKBuild</key>
|
||||
<string>25B74</string>
|
||||
<key>DTSDKName</key>
|
||||
<string>macosx26.1</string>
|
||||
<key>DTXcode</key>
|
||||
<string>2611</string>
|
||||
<key>DTXcodeBuild</key>
|
||||
<string>17B100</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
</dict>
|
||||
</plist>
|
||||
Binary file not shown.
@ -1,491 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Prole build script — builds a macOS .app bundle without launching Xcode
|
||||
# Requirements: Xcode Command Line Tools (swiftc, codesign, plutil, lipo, xcodebuild)
|
||||
|
||||
# Human-friendly app bundle name (can contain spaces)
|
||||
APP_NAME="Prole Tools"
|
||||
# SwiftPM product (executable) name as defined in Package.swift -> products/executable(name: ...)
|
||||
# This must match the actual built binary filename produced by SwiftPM.
|
||||
EXECUTABLE_NAME="Prole"
|
||||
# Bundle identifiers cannot contain spaces — keep a stable reverse-DNS id
|
||||
BUNDLE_ID="org.prole.ProleTools"
|
||||
MIN_MACOS="12.0"
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$SCRIPT_DIR"
|
||||
SRC_DIR="$ROOT_DIR/Sources"
|
||||
BUILD_DIR="$ROOT_DIR/.build-cli"
|
||||
DIST_DIR="$ROOT_DIR/dist"
|
||||
APP_DIR="$DIST_DIR/${APP_NAME}.app"
|
||||
CONTENTS_DIR="$APP_DIR/Contents"
|
||||
MACOS_DIR="$CONTENTS_DIR/MacOS"
|
||||
RESOURCES_DIR="$CONTENTS_DIR/Resources"
|
||||
INFO_PLIST="$CONTENTS_DIR/Info.plist"
|
||||
PARENT_DIR="$(cd "$ROOT_DIR/.." && pwd)"
|
||||
LOG_DIR="$BUILD_DIR/logs"
|
||||
|
||||
ARCH_CURRENT="$(uname -m)" # arm64 or x86_64
|
||||
|
||||
# Deps staging directories
|
||||
DEPS_DIR="$BUILD_DIR/deps"
|
||||
DEPS_SRC_DIR="$DEPS_DIR/src"
|
||||
DEPS_OUT_DIR="$DEPS_DIR/out"
|
||||
DEPS_LOGS_DIR="$DEPS_OUT_DIR/logs"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [command] [options]
|
||||
|
||||
Commands:
|
||||
build Build ${APP_NAME}.app for the current architecture (default)
|
||||
build-universal Build a universal (arm64+x86_64) ${APP_NAME}.app
|
||||
clean Remove build artifacts
|
||||
run Build (if needed) and run the app
|
||||
debug Build (if needed) and run in foreground with verbose logs
|
||||
package Zip the built app into dist/${APP_NAME}.zip
|
||||
|
||||
Options (for build):
|
||||
--arch <arch> Build for a specific arch (arm64 or x86_64). Defaults to host arch.
|
||||
--verbose Print verbose build commands (same as PROLE_VERBOSE=1)
|
||||
|
||||
Examples:
|
||||
./build.sh build
|
||||
./build.sh build --arch arm64
|
||||
./build.sh build-universal
|
||||
./build.sh run
|
||||
./build.sh package
|
||||
|
||||
Dependencies:
|
||||
IRC is built via Swift Package Manager.
|
||||
|
||||
Environment overrides (optional):
|
||||
KEEP_DEPS=1 Keep the deps staging directory after the build (for debugging)
|
||||
PROLE_VERBOSE=1 Stream verbose output from SwiftPM/xcodebuild and echo commands
|
||||
EOF
|
||||
}
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$BUILD_DIR" "$DIST_DIR" "$MACOS_DIR" "$RESOURCES_DIR" "$CONTENTS_DIR/Frameworks" "$DEPS_SRC_DIR" "$DEPS_OUT_DIR" "$LOG_DIR" "$DEPS_LOGS_DIR"
|
||||
}
|
||||
|
||||
# Determine verbosity from env/flags
|
||||
VERBOSE="${PROLE_VERBOSE:-0}"
|
||||
if [[ "$VERBOSE" = "1" ]]; then
|
||||
# Enable shell tracing for more insight
|
||||
set -x
|
||||
fi
|
||||
|
||||
# Resolve a SwiftPM-capable command (prefer xcrun swift on macOS)
|
||||
resolve_swiftpm() {
|
||||
if command -v xcrun >/dev/null 2>&1 && xcrun swift --version >/dev/null 2>&1; then
|
||||
echo "xcrun swift"
|
||||
return 0
|
||||
fi
|
||||
if command -v swift >/dev/null 2>&1; then
|
||||
if swift build --help >/dev/null 2>&1; then
|
||||
echo "swift"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# Fallback to package form via xcrun if available
|
||||
if command -v xcrun >/dev/null 2>&1 && xcrun swift package --help >/dev/null 2>&1; then
|
||||
echo "xcrun swift"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
SWIFTPM_CMD=$(resolve_swiftpm || true)
|
||||
if [[ -z "${SWIFTPM_CMD:-}" ]]; then
|
||||
echo "[spm] error: Swift Package Manager not found. Install Xcode Command Line Tools: xcode-select --install" >&2
|
||||
xcodebuild -version 2>/dev/null || true
|
||||
swift --version 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
echo "[spm] Using SwiftPM: ${SWIFTPM_CMD}"
|
||||
|
||||
# Read a key from prole.properties (very simple parser)
|
||||
prop_get() {
|
||||
local key="$1"
|
||||
local file="$ROOT_DIR/prole.properties"
|
||||
if [[ -f "$file" ]]; then
|
||||
local line
|
||||
line=$(grep -E "^${key}=" "$file" | tail -n1 || true)
|
||||
if [[ -n "$line" ]]; then
|
||||
echo "${line#*=}"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
gen_statusbar_icon_png() {
|
||||
# Generates a small monochrome template PNG for the status bar (18x18)
|
||||
local out_png="$RESOURCES_DIR/statusIcon.png"
|
||||
# Render a simple 'P' using AppKit to avoid extra deps
|
||||
/usr/bin/env xcrun swift -F /System/Library/PrivateFrameworks - <<'SWIFT' "$out_png"
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count > 1 else { exit(2) }
|
||||
let path = args[1]
|
||||
let size = NSSize(width: 18, height: 18)
|
||||
let img = NSImage(size: size)
|
||||
img.lockFocus()
|
||||
NSColor.clear.setFill()
|
||||
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = .center
|
||||
let attrs: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedSystemFont(ofSize: 14, weight: .bold),
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.paragraphStyle: paragraph
|
||||
]
|
||||
let s = NSString(string: "P")
|
||||
let rect = NSRect(x: 0, y: -2, width: size.width, height: size.height)
|
||||
s.draw(in: rect, withAttributes: attrs)
|
||||
img.unlockFocus()
|
||||
|
||||
guard let tiff = img.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) else {
|
||||
exit(3)
|
||||
}
|
||||
try! png.write(to: URL(fileURLWithPath: path))
|
||||
SWIFT
|
||||
# Mark as template via extended attribute for system tinting (optional)
|
||||
}
|
||||
|
||||
gen_app_icns() {
|
||||
# Create an .icns for the app. Prefer a configured source image (ui.icon),
|
||||
# otherwise fall back to generating a bold 'P'.
|
||||
local icon_name="${APP_NAME}"
|
||||
local iconset_dir="$BUILD_DIR/${icon_name}.iconset"
|
||||
rm -rf "$iconset_dir"
|
||||
mkdir -p "$iconset_dir"
|
||||
|
||||
local base_png="$BUILD_DIR/${icon_name}_1024.png"
|
||||
|
||||
# Try configured ui.icon relative to repo root
|
||||
local ui_icon
|
||||
ui_icon="$(prop_get ui.icon || true)"
|
||||
if [[ -n "$ui_icon" && -f "$PARENT_DIR/$ui_icon" ]]; then
|
||||
# Use the provided icon image as base; if not already 1024x1024, sips will resize for each size
|
||||
cp "$PARENT_DIR/$ui_icon" "$base_png"
|
||||
else
|
||||
# Generate a 1024 base PNG using AppKit so we don't depend on ImageMagick/Pillow
|
||||
/usr/bin/env xcrun swift -F /System/Library/PrivateFrameworks - <<'SWIFT' "$base_png"
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
let args = CommandLine.arguments
|
||||
guard args.count > 1 else { exit(2) }
|
||||
let outPath = args[1]
|
||||
let size = NSSize(width: 1024, height: 1024)
|
||||
let img = NSImage(size: size)
|
||||
img.lockFocus()
|
||||
NSColor.clear.setFill()
|
||||
NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill()
|
||||
|
||||
// Draw a rounded rect background to make it look like an app icon
|
||||
let bgRect = NSRect(x: 0, y: 0, width: 1024, height: 1024)
|
||||
let radius: CGFloat = 220
|
||||
let roundRectPath = NSBezierPath(roundedRect: bgRect, xRadius: radius, yRadius: radius)
|
||||
NSColor.windowBackgroundColor.setFill()
|
||||
roundRectPath.fill()
|
||||
|
||||
// Draw the letter 'P' centered
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = .center
|
||||
let attrs: [NSAttributedString.Key: Any] = [
|
||||
.font: NSFont.monospacedSystemFont(ofSize: 720, weight: .black),
|
||||
.foregroundColor: NSColor.labelColor,
|
||||
.paragraphStyle: paragraph
|
||||
]
|
||||
let s = NSString(string: "P")
|
||||
let rect = NSRect(x: 0, y: 70, width: 1024, height: 820)
|
||||
s.draw(in: rect, withAttributes: attrs)
|
||||
img.unlockFocus()
|
||||
|
||||
guard let tiff = img.tiffRepresentation,
|
||||
let rep = NSBitmapImageRep(data: tiff),
|
||||
let png = rep.representation(using: .png, properties: [:]) else {
|
||||
exit(3)
|
||||
}
|
||||
try! png.write(to: URL(fileURLWithPath: outPath))
|
||||
SWIFT
|
||||
fi
|
||||
|
||||
# Create all iconset sizes from the base image
|
||||
for s in 16 32 128 256 512; do
|
||||
/usr/bin/sips -z "$s" "$s" "$base_png" --out "$iconset_dir/icon_${s}x${s}.png" >/dev/null
|
||||
/usr/bin/sips -z "$((s*2))" "$((s*2))" "$base_png" --out "$iconset_dir/icon_${s}x${s}@2x.png" >/dev/null
|
||||
done
|
||||
|
||||
# Build icns
|
||||
/usr/bin/iconutil -c icns "$iconset_dir" -o "$RESOURCES_DIR/${icon_name}.icns"
|
||||
}
|
||||
|
||||
gen_plist() {
|
||||
cat > "$INFO_PLIST" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key><string>en</string>
|
||||
<key>CFBundleExecutable</key><string>${APP_NAME}</string>
|
||||
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundleName</key><string>${APP_NAME}</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>CFBundleVersion</key><string>1</string>
|
||||
<key>LSMinimumSystemVersion</key><string>${MIN_MACOS}</string>
|
||||
<key>NSHighResolutionCapable</key><true/>
|
||||
<key>NSPrincipalClass</key><string>NSApplication</string>
|
||||
<key>LSApplicationCategoryType</key><string>public.app-category.developer-tools</string>
|
||||
<!-- UIElement must be false so the app can present a normal window and app menu when in Application Window mode. -->
|
||||
<key>LSUIElement</key><false/>
|
||||
<key>CFBundleIconFile</key><string>${APP_NAME}.icns</string>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
}
|
||||
|
||||
copy_extra_resources() {
|
||||
# Copy default properties file if present
|
||||
local props_src="$ROOT_DIR/prole.properties"
|
||||
if [[ -f "$props_src" ]]; then
|
||||
cp "$props_src" "$RESOURCES_DIR/prole.properties"
|
||||
echo "[resources] Copied prole.properties into Resources"
|
||||
else
|
||||
echo "[resources] prole.properties not found at $props_src (skipping)"
|
||||
fi
|
||||
}
|
||||
|
||||
# --- Dependency preparation ---
|
||||
|
||||
prepare_dependencies() {
|
||||
ensure_dirs
|
||||
}
|
||||
|
||||
cleanup_dependencies() {
|
||||
if [[ "${KEEP_DEPS:-0}" = "1" ]]; then
|
||||
echo "[deps] KEEP_DEPS=1 set; preserving $DEPS_DIR"
|
||||
else
|
||||
rm -rf "$DEPS_DIR"
|
||||
echo "[deps] Cleaned deps staging directory"
|
||||
fi
|
||||
}
|
||||
|
||||
# Build via Swift Package Manager and return path to built binary on stdout
|
||||
spm_build_binary() {
|
||||
local arch="$1"
|
||||
echo "[spm] Building (Release) for arch=${arch}" >&2
|
||||
# ROOT_DIR already points to prole-app; build from there so Package.swift is visible.
|
||||
pushd "$ROOT_DIR" >/dev/null || return 1
|
||||
mkdir -p "$LOG_DIR"
|
||||
# Build via SwiftPM (Package.swift in prole-app). All dependencies managed by SPM.
|
||||
# Prepare argument arrays to avoid word-splitting issues
|
||||
local spm_verbose_flag=()
|
||||
if [[ "$VERBOSE" = "1" ]]; then
|
||||
spm_verbose_flag=( -v )
|
||||
fi
|
||||
local build_flags=( -c release --arch "$arch" \
|
||||
-Xlinker -rpath -Xlinker "@executable_path/../Frameworks" )
|
||||
|
||||
# Turn SWIFTPM_CMD (e.g., "xcrun swift") into an array
|
||||
local -a SWIFTPM_ARR
|
||||
# shellcheck disable=SC2206
|
||||
SWIFTPM_ARR=( $SWIFTPM_CMD )
|
||||
|
||||
# If verbose, stream output to stdout and tee to log; otherwise, write only to log
|
||||
if [[ "$VERBOSE" = "1" ]]; then
|
||||
echo "[spm] Executing: ${SWIFTPM_CMD} build ${spm_verbose_flag[*]} ${build_flags[*]}"
|
||||
"${SWIFTPM_ARR[@]}" build "${spm_verbose_flag[@]}" "${build_flags[@]}" 2>&1 | tee "$LOG_DIR/spm-build-${arch}.log"
|
||||
local rc=${PIPESTATUS[0]}
|
||||
if [[ $rc -ne 0 ]]; then popd >/dev/null; return $rc; fi
|
||||
else
|
||||
"${SWIFTPM_ARR[@]}" build "${build_flags[@]}" >"$LOG_DIR/spm-build-${arch}.log" 2>&1 || { popd >/dev/null; return 1; }
|
||||
fi
|
||||
popd >/dev/null
|
||||
# Note: SwiftPM output binary name equals EXECUTABLE_NAME (not APP_NAME)
|
||||
local candidate1="$ROOT_DIR/.build/${arch}-apple-macosx/release/${EXECUTABLE_NAME}"
|
||||
local candidate2="$ROOT_DIR/.build/release/${EXECUTABLE_NAME}"
|
||||
if [[ -x "$candidate1" ]]; then
|
||||
echo "$candidate1"; return 0
|
||||
fi
|
||||
if [[ -x "$candidate2" ]]; then
|
||||
echo "$candidate2"; return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
codesign_app() {
|
||||
echo "[codesign] Ad-hoc signing ${APP_DIR}"
|
||||
xcrun codesign --force --deep -s - "$APP_DIR"
|
||||
}
|
||||
|
||||
|
||||
# Copy SwiftPM-produced dynamic libraries (e.g., libRoyalVNCKit.dylib) into the app bundle
|
||||
embed_spm_dylibs() {
|
||||
local arch="$1"
|
||||
local spm_lib_dir="$ROOT_DIR/.build/${arch}-apple-macosx/release"
|
||||
if [[ ! -d "$spm_lib_dir" ]]; then
|
||||
spm_lib_dir="$ROOT_DIR/.build/release"
|
||||
fi
|
||||
mkdir -p "$CONTENTS_DIR/Frameworks"
|
||||
local found=0
|
||||
if compgen -G "$spm_lib_dir/*.dylib" > /dev/null; then
|
||||
for dyl in "$spm_lib_dir"/*.dylib; do
|
||||
found=1
|
||||
echo "[embed] Copying $(basename "$dyl") into Frameworks"
|
||||
cp -f "$dyl" "$CONTENTS_DIR/Frameworks/"
|
||||
done
|
||||
fi
|
||||
if [[ $found -eq 1 ]]; then
|
||||
echo "[embed] Codesigning embedded dylibs"
|
||||
# Sign all copied dylibs
|
||||
find "$CONTENTS_DIR/Frameworks" -name "*.dylib" -print0 | while IFS= read -r -d '' f; do
|
||||
xcrun codesign --force -s - "$f"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
build_one_arch() {
|
||||
local arch="$1"
|
||||
# Default clean at the start of every build
|
||||
clean || true
|
||||
# Constrain dependency builds to the requested architecture to avoid toolchain incompatibilities
|
||||
export PROLE_BUILD_ARCH="$arch"
|
||||
prepare_dependencies
|
||||
ensure_dirs
|
||||
gen_plist
|
||||
gen_app_icns
|
||||
gen_statusbar_icon_png
|
||||
copy_extra_resources
|
||||
local built_bin
|
||||
built_bin=$(spm_build_binary "$arch" | tail -n1) || { echo "[spm] build failed" >&2; exit 1; }
|
||||
mkdir -p "$MACOS_DIR"
|
||||
cp "$built_bin" "$MACOS_DIR/${APP_NAME}"
|
||||
# Embed any SwiftPM dynamic libs into the app bundle
|
||||
embed_spm_dylibs "$arch"
|
||||
# Ensure the app binary can locate embedded dylibs in Contents/Frameworks at runtime
|
||||
echo "[rpath] Adding @executable_path/../Frameworks to app binary rpaths"
|
||||
xcrun install_name_tool -add_rpath "@executable_path/../Frameworks" "$MACOS_DIR/${APP_NAME}" 2>/dev/null || true
|
||||
codesign_app
|
||||
echo "Built: $APP_DIR"
|
||||
echo "[summary] App binary: $(lipo -info "$MACOS_DIR/${APP_NAME}" 2>/dev/null || echo unknown)"
|
||||
echo "[summary] Embedded frameworks (.framework):"
|
||||
/usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type d -name "*.framework" -exec basename {} \; | sed 's/^/ - /'
|
||||
echo "[summary] Embedded dynamic libraries (.dylib):"
|
||||
/usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type f -name "*.dylib" -exec basename {} \; | sed 's/^/ - /'
|
||||
cleanup_dependencies
|
||||
}
|
||||
|
||||
build_universal() {
|
||||
# Default clean at the start of every universal build
|
||||
clean || true
|
||||
# Build dependencies for both arches in universal build
|
||||
unset PROLE_BUILD_ARCH || true
|
||||
prepare_dependencies
|
||||
ensure_dirs
|
||||
gen_plist
|
||||
local built_arm64; built_arm64=$(spm_build_binary arm64 | tail -n1) || { echo "[spm] arm64 build failed" >&2; exit 1; }
|
||||
local built_x86; built_x86=$(spm_build_binary x86_64 | tail -n1) || { echo "[spm] x86_64 build failed" >&2; exit 1; }
|
||||
mkdir -p "$MACOS_DIR"
|
||||
xcrun lipo -create -output "$MACOS_DIR/${APP_NAME}" "$built_arm64" "$built_x86"
|
||||
codesign_app
|
||||
echo "Built universal: $APP_DIR"
|
||||
echo "[summary] App binary: $(lipo -info "$MACOS_DIR/${APP_NAME}" 2>/dev/null || echo unknown)"
|
||||
echo "[summary] Embedded frameworks (.framework):"
|
||||
/usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type d -name "*.framework" -exec basename {} \; | sed 's/^/ - /'
|
||||
echo "[summary] Embedded dynamic libraries (.dylib):"
|
||||
/usr/bin/find "$CONTENTS_DIR/Frameworks" -maxdepth 1 -type f -name "*.dylib" -exec basename {} \; | sed 's/^/ - /'
|
||||
cleanup_dependencies
|
||||
}
|
||||
|
||||
clean() {
|
||||
rm -rf "$BUILD_DIR" "$DIST_DIR"
|
||||
echo "Cleaned build artifacts."
|
||||
}
|
||||
|
||||
run_app() {
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
"$0" build
|
||||
fi
|
||||
echo "[run] Launching ${APP_NAME}.app"
|
||||
# Launch app in background without activating it; return immediately.
|
||||
# Then confirm it is running and print its PID.
|
||||
open -gn "$APP_DIR"
|
||||
# Wait briefly for the app to start and obtain PID
|
||||
for i in {1..20}; do
|
||||
PID=$(pgrep -x "$APP_NAME" || true)
|
||||
if [ -n "${PID:-}" ]; then
|
||||
echo "[run] ${APP_NAME} is running (pid: $PID)."
|
||||
return 0
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
echo "[run] Warning: could not confirm ${APP_NAME} is running. Check Console/Activity Monitor." >&2
|
||||
}
|
||||
|
||||
package_app() {
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
"$0" build
|
||||
fi
|
||||
local zip="$DIST_DIR/${APP_NAME}.zip"
|
||||
(cd "$DIST_DIR" && /usr/bin/zip -qry "$(basename "$zip")" "$(basename "$APP_DIR")")
|
||||
echo "Packaged: $zip"
|
||||
}
|
||||
|
||||
cmd="${1:-build}"
|
||||
shift || true
|
||||
|
||||
case "$cmd" in
|
||||
-h|--help|help)
|
||||
usage ;;
|
||||
clean)
|
||||
clean ;;
|
||||
build)
|
||||
arch="${ARCH_CURRENT}"
|
||||
# Allow per-invocation verbose flag
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--arch) arch="$2"; shift 2 ;;
|
||||
--verbose) VERBOSE="1"; shift ;;
|
||||
*) echo "Unknown option: $1"; usage; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
build_one_arch "$arch" ;;
|
||||
build-universal)
|
||||
build_universal ;;
|
||||
run)
|
||||
run_app ;;
|
||||
debug)
|
||||
# Build if needed, then run the binary directly in the foreground
|
||||
if [ ! -d "$APP_DIR" ]; then
|
||||
"$0" build
|
||||
fi
|
||||
BIN="$MACOS_DIR/${APP_NAME}"
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "[debug] error: binary not found at $BIN" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[debug] Running ${APP_NAME} in foreground with verbose logs"
|
||||
echo "[debug] Press Ctrl+C to terminate. To quit from UI, use the context menu or Cmd+Q."
|
||||
export PROLESTATUS_DEBUG=1
|
||||
"$BIN"
|
||||
status=$?
|
||||
echo "[debug] ${APP_NAME} exited with status ${status}"
|
||||
exit $status
|
||||
;;
|
||||
package)
|
||||
package_app ;;
|
||||
*)
|
||||
echo "Unknown command: $cmd"; usage; exit 1 ;;
|
||||
esac
|
||||
@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Prole debug script — runs a build with logs preserved and prints the most relevant tails
|
||||
# Usage:
|
||||
# ./debug.sh # build for host arch and print log tails
|
||||
# ./debug.sh --universal # build universal and print log tails
|
||||
#
|
||||
# Notes:
|
||||
# - This script forces KEEP_DEPS=1 so dependency artifacts and logs are retained.
|
||||
# - It will not stop on build failure; it captures the exit code and prints logs.
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$SCRIPT_DIR"
|
||||
BUILD_DIR="$ROOT_DIR/.build-cli"
|
||||
DEPS_OUT_DIR="$BUILD_DIR/deps/out"
|
||||
DEPS_LOGS_DIR="$DEPS_OUT_DIR/logs"
|
||||
APP_LOGS_DIR="$BUILD_DIR/logs"
|
||||
|
||||
cmd="build"
|
||||
if [[ ${1:-} == "--universal" ]]; then
|
||||
cmd="build-universal"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
echo "[debug] Xcode:"
|
||||
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -version || xcodebuild -version || true
|
||||
echo
|
||||
|
||||
echo "[debug] Cleaning prior artifacts (but will preserve new deps via KEEP_DEPS=1)"
|
||||
KEEP_DEPS=1 "$SCRIPT_DIR/build.sh" clean || true
|
||||
|
||||
echo "[debug] Starting ${cmd} with KEEP_DEPS=1"
|
||||
set +e
|
||||
KEEP_DEPS=1 "$SCRIPT_DIR/build.sh" "$cmd" "$@"
|
||||
status=$?
|
||||
set -e
|
||||
echo "[debug] build.sh finished with status: $status"
|
||||
|
||||
echo "[debug] ===== Dependency logs (RoyalVNCKit) ====="
|
||||
for f in \
|
||||
"$DEPS_LOGS_DIR/royalvnc-archive-arm64.log" \
|
||||
"$DEPS_LOGS_DIR/royalvnc-archive-x86_64.log" \
|
||||
"$DEPS_LOGS_DIR/royalvnc-create-xcframework.log" \
|
||||
; do
|
||||
if [[ -f "$f" ]]; then
|
||||
echo "----- tail: ${f} -----"
|
||||
tail -n 200 "$f" || true
|
||||
echo
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[debug] ===== App compile/link logs ====="
|
||||
for f in \
|
||||
"$APP_LOGS_DIR/swiftc-arm64.log" \
|
||||
"$APP_LOGS_DIR/swiftc-x86_64.log" \
|
||||
; do
|
||||
if [[ -f "$f" ]]; then
|
||||
echo "----- tail: ${f} -----"
|
||||
tail -n 200 "$f" || true
|
||||
echo
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[debug] ===== Summary ====="
|
||||
if [[ -d "$DEPS_LOGS_DIR" ]]; then
|
||||
echo "Dependency logs directory: $DEPS_LOGS_DIR"
|
||||
fi
|
||||
if [[ -d "$APP_LOGS_DIR" ]]; then
|
||||
echo "App logs directory: $APP_LOGS_DIR"
|
||||
fi
|
||||
echo "build.sh exit status: $status (0 means success)"
|
||||
exit $status
|
||||
@ -1,40 +0,0 @@
|
||||
# Prole default endpoints (bundled). Override in:
|
||||
# ~/Library/Application Support/Prole/prole.properties
|
||||
# using the same key=value format.
|
||||
|
||||
# UI assets (relative to repo root when building; file name within app bundle at runtime)
|
||||
# New canonical keys used by installer and app
|
||||
icon=img/proleIcon.png
|
||||
background=img/proleLogoSepia.png
|
||||
|
||||
# Legacy keys retained for backward compatibility
|
||||
ui.icon=img/proleIcon.png
|
||||
ui.background=img/proleLogoSepia.png
|
||||
ui.welcometxt=installer/welcome.txt
|
||||
|
||||
# Dev port-forward supervision
|
||||
# Note: Port mappings are now sourced from $PROLE_HOME/conf/port-mapping.cfg.
|
||||
# The legacy pf.* keys are intentionally omitted from the bundled defaults.
|
||||
pf.enabled=true
|
||||
|
||||
# Core service
|
||||
svc.host=svc.prole.org
|
||||
svc.port=443
|
||||
|
||||
# k3s aggregate ? replace legacy raspberry with retropie
|
||||
k3s.retropie.host=retropie.prole.org
|
||||
k3s.retropie.port=6443
|
||||
|
||||
# local k3d
|
||||
k3d.local.host=localhost
|
||||
k3d.local.port=6443
|
||||
|
||||
# internal postgres
|
||||
postgres.host=k3s.prole.org
|
||||
postgres.port=5432
|
||||
|
||||
# Reserved for future cloud endpoints
|
||||
# aws.eks.host=
|
||||
# aws.eks.port=
|
||||
# gcp.gke.host=
|
||||
# gcp.gke.port=
|
||||
@ -1,16 +0,0 @@
|
||||
FROM maven:3.9-eclipse-temurin-21 AS build
|
||||
|
||||
WORKDIR /workspace
|
||||
COPY pom.xml ./
|
||||
COPY src ./src
|
||||
RUN mvn -q -DskipTests package
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /workspace/target/prole-auth-0.0.1-SNAPSHOT.jar /app/app.jar
|
||||
|
||||
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
@ -1,7 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: prole-auth-kerberos
|
||||
data:
|
||||
# Kerberos HTTP service principal for SPNEGO (must match keytab)
|
||||
servicePrincipal: "HTTP/api.prole.org@PROLE.ORG"
|
||||
@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.1.0</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>org.prole</groupId>
|
||||
<artifactId>prole-auth</artifactId>
|
||||
<version>0.0.1-SNAPSHOT</version>
|
||||
<name>prole-auth</name>
|
||||
<description>Knoe auth service (Kerberos/SPNEGO + session verify)</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
|
||||
</project>
|
||||
@ -1,27 +0,0 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
prole:
|
||||
auth:
|
||||
enabled: false
|
||||
cookieName: prole_session
|
||||
cookieDomain: ${PROLE_AUTH_COOKIE_DOMAIN:.prole.org}
|
||||
sessionTtl: 8h
|
||||
# REQUIRED in production when enabled. Provide via env: PROLE_AUTH_SESSION_SECRET
|
||||
sessionSecret: ""
|
||||
emailDomain: prole.org
|
||||
formEnabled: false
|
||||
# Comma-separated list of bare usernames granted admin group membership.
|
||||
# Override via env: PROLE_AUTH_ADMIN_PRINCIPALS=admin
|
||||
adminPrincipals: []
|
||||
kerberos:
|
||||
# REQUIRED for SPNEGO when enabled. Provide via env.
|
||||
servicePrincipal: ""
|
||||
keytabPath: ""
|
||||
realm: ""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,15 +0,0 @@
|
||||
org/prole/auth/kerberos/KerberosPasswordService$FixedCallbackHandler.class
|
||||
org/prole/auth/session/SessionUser.class
|
||||
org/prole/auth/web/VerifyController.class
|
||||
org/prole/auth/ProleAuthApplication.class
|
||||
org/prole/auth/user/PrincipalNormalizer.class
|
||||
org/prole/auth/kerberos/KerberosSpnegoService.class
|
||||
org/prole/auth/kerberos/KerberosPasswordService$PasswordJaasConfiguration.class
|
||||
org/prole/auth/session/SessionTokenService.class
|
||||
org/prole/auth/kerberos/KerberosSpnegoService$Result.class
|
||||
org/prole/auth/kerberos/KerberosPasswordService.class
|
||||
org/prole/auth/config/AuthProperties.class
|
||||
org/prole/auth/kerberos/KerberosSpnegoService$KeytabJaasConfiguration.class
|
||||
org/prole/auth/config/KerberosProperties.class
|
||||
org/prole/auth/web/LoginController.class
|
||||
org/prole/auth/session/SessionTokenService$TokenPayload.class
|
||||
@ -1,10 +0,0 @@
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/config/KerberosProperties.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/config/AuthProperties.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/ProleAuthApplication.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/kerberos/KerberosPasswordService.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/session/SessionTokenService.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/user/PrincipalNormalizer.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/web/VerifyController.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/web/LoginController.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/session/SessionUser.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/main/java/org/prole/auth/kerberos/KerberosSpnegoService.java
|
||||
@ -1,2 +0,0 @@
|
||||
org/prole/auth/web/VerifyControllerTest.class
|
||||
org/prole/auth/session/SessionTokenServiceTest.class
|
||||
@ -1,2 +0,0 @@
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/test/java/org/prole/auth/session/SessionTokenServiceTest.java
|
||||
/Users/chrisfu/dev/prole/prole-auth/src/test/java/org/prole/auth/web/VerifyControllerTest.java
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@ -1,57 +0,0 @@
|
||||
FROM ubuntu:24.04
|
||||
USER root
|
||||
|
||||
# update apt
|
||||
RUN apt-get update && apt-get install -y apt-utils
|
||||
|
||||
# install lib
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y sudo \
|
||||
curl \
|
||||
wget \
|
||||
dialog \
|
||||
gpg \
|
||||
ca-certificates \
|
||||
lsb-release \
|
||||
locales
|
||||
|
||||
# set locale
|
||||
RUN echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen
|
||||
RUN locale-gen
|
||||
ENV LANG=en_US.utf8
|
||||
|
||||
# install nfs & iscsi
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y nfs-common open-iscsi
|
||||
|
||||
# fix for mssql server ldap-2.5 dep
|
||||
RUN wget http://archive.ubuntu.com/ubuntu/pool/main/o/openldap/libldap-2.5-0_2.5.11+dfsg-1~exp1ubuntu3_amd64.deb
|
||||
RUN dpkg -i libldap-2.5-0_2.5.11+dfsg-1~exp1ubuntu3_amd64.deb
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y libcurl4 libssl-dev libgnutls30
|
||||
|
||||
# mssql keys
|
||||
RUN curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc
|
||||
|
||||
# mssql repos
|
||||
RUN curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list | sudo tee /etc/apt/sources.list.d/mssql-server-2022.list
|
||||
RUN curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list
|
||||
|
||||
# install mssql server
|
||||
ENV ACCEPT_EULA="Y"
|
||||
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y mssql-server \
|
||||
mssql-tools18 \
|
||||
systemctl \
|
||||
unixodbc-dev
|
||||
|
||||
# make data directory
|
||||
RUN mkdir -p /var/opt/mssql/data
|
||||
RUN chown -R mssql /var/opt/mssql/data
|
||||
|
||||
# Cleanup
|
||||
RUN apt-get clean
|
||||
RUN rm -rf /var/lib/apt/lists
|
||||
|
||||
EXPOSE 1433
|
||||
|
||||
USER mssql
|
||||
|
||||
# Run SQL Server process
|
||||
ENTRYPOINT [ "/opt/mssql/bin/sqlservr" ]
|
||||
@ -1,36 +0,0 @@
|
||||
FROM mcr.microsoft.com/mssql/server:2022-latest
|
||||
USER root
|
||||
|
||||
# update apt
|
||||
RUN apt-get update && apt-get install -y apt-utils
|
||||
|
||||
# install lib
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y sudo \
|
||||
curl \
|
||||
dialog \
|
||||
gpg \
|
||||
ca-certificates \
|
||||
lsb-release \
|
||||
locales
|
||||
|
||||
# set locale
|
||||
RUN echo 'en_US.UTF-8 UTF-8' >> /etc/locale.gen
|
||||
RUN locale-gen
|
||||
ENV LANG=en_US.utf8
|
||||
|
||||
# install nfs & iscsi
|
||||
RUN DEBIAN_FRONTEND=noninteractive apt-get install -y nfs-common open-iscsi
|
||||
|
||||
# mssql keys
|
||||
RUN curl https://packages.microsoft.com/keys/microsoft.asc | sudo tee /etc/apt/trusted.gpg.d/microsoft.asc
|
||||
|
||||
# mssql repos
|
||||
RUN curl -fsSL https://packages.microsoft.com/config/ubuntu/22.04/mssql-server-2022.list | sudo tee /etc/apt/sources.list.d/mssql-server-2022.list
|
||||
RUN curl https://packages.microsoft.com/config/ubuntu/22.04/prod.list | sudo tee /etc/apt/sources.list.d/mssql-release.list
|
||||
|
||||
EXPOSE 1433
|
||||
|
||||
# USER mssql
|
||||
|
||||
# Run SQL Server process
|
||||
# CMD ["/opt/mssql/bin/sqlservr"]
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
service: db
|
||||
name: prole-mssql-db
|
||||
spec:
|
||||
ports:
|
||||
- name: "mssql-server"
|
||||
port: 1433
|
||||
targetPort: 1433
|
||||
selector:
|
||||
service: db
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user