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:
chrisfu 2026-04-22 09:28:14 -07:00
parent 34de8a5f34
commit f44ce32a27
138 changed files with 303 additions and 458012 deletions

View File

@ -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>

View File

@ -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
View 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`

View File

@ -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

View File

@ -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/*

View File

@ -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);
}
}

View File

@ -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";
}
}

View File

@ -1,3 +0,0 @@
server.port=8080
management.endpoints.web.exposure.include=health,info

View File

@ -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() {
}
}

View File

@ -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>

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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<>();

View File

@ -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 = "";

View File

@ -1,4 +1,4 @@
package org.prole.auth.kerberos;
package org.prole.authority.kerberos;
import java.io.IOException;
import java.util.Map;

View File

@ -1,4 +1,4 @@
package org.prole.auth.kerberos;
package org.prole.authority.kerberos;
import java.security.PrivilegedExceptionAction;
import java.util.Map;

View File

@ -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;

View File

@ -1,4 +1,4 @@
package org.prole.auth.session;
package org.prole.authority.session;
import java.util.List;

View File

@ -1,4 +1,4 @@
package org.prole.auth.user;
package org.prole.authority.user;
import java.util.Locale;
import java.util.Optional;

View File

@ -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) {

View File

@ -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;

View 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>

View File

@ -1,3 +0,0 @@
server.port=8080
management.endpoints.web.exposure.include=health,info

View File

@ -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.

View File

@ -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() {
}
}

View File

@ -1,4 +1,4 @@
package org.prole.auth.session;
package org.prole.authority.session;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Clock;

View File

@ -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;

View 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>

View File

@ -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"

View File

@ -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:

View File

@ -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 "$@"

View File

@ -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$/&lt;MagicMock name='mock.Entry().get().strip().strip()' id='4677834960'&gt;" />
<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>

View File

@ -22,7 +22,7 @@ datas = [
# Binaries to include (with execute permissions)
binaries = [
('prole-net/prole-agent', 'prole-net'),
('scan/network-agent', 'scan'),
]
# Hidden imports

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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
}

View File

@ -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")
]
)
]
)

View File

@ -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 statusbar overlay for persistent ataglance 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` (adhoc 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 adhoc 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 topright. A small control bar bottomleft 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.
- Statusbar “P” icon (rightclick): Show/Hide Status Bar Icon, Show/Hide Application Window, Refresh Now, Quit.
Configuration
- Endpoint configuration is provided via Javastyle `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 adhoc logging, search the sources for `dlog("…")` and run via `./build.sh debug` to watch stdout.
Security & signing
- The `.app` bundle is adhoc signed by default. For distribution, replace with a Developer ID signature and notarize as appropriate for your environment.
License
- See the repository `LICENSE` file.

View 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 statusbar 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 clickthrough 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 rightclick 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 2state 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)
}
}

View File

@ -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) }
}
}

View File

@ -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)
}
}

View File

@ -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())
}

View File

@ -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 }
}

View File

@ -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())
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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: "&amp;")
.replacingOccurrences(of: "\"", with: "&quot;")
.replacingOccurrences(of: "<", with: "&lt;")
.replacingOccurrences(of: ">", with: "&gt;")
}
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)
}
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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"
}
}
}

View File

@ -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
}
}

View File

@ -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 (dont 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()
}
}

View File

@ -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()

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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=

View File

@ -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"]

View File

@ -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"

View File

@ -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>

View File

@ -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: ""

View File

@ -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

View File

@ -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

View File

@ -1,2 +0,0 @@
org/prole/auth/web/VerifyControllerTest.class
org/prole/auth/session/SessionTokenServiceTest.class

View File

@ -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

View File

@ -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" ]

View File

@ -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

View File

@ -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