Compare commits
No commits in common. "main" and "develop" have entirely different histories.
13
Makefile
Normal file
@ -0,0 +1,13 @@
|
||||
.PHONY: setup run test build-mac-selfcontained
|
||||
|
||||
setup:
|
||||
./web/run.sh --setup-only
|
||||
|
||||
run:
|
||||
./web/run.sh
|
||||
|
||||
test: setup
|
||||
. .venv/bin/activate && PYTHONPATH=web/src pytest -q web/src/tests
|
||||
|
||||
build-mac-selfcontained:
|
||||
./scripts/build_selfcontained_mac_app.sh
|
||||
63
README.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Real Bars vs Fake Bars Trend Analyzer
|
||||
|
||||
A Python web app wrapped by a native macOS shell.
|
||||
|
||||
## Standardized Layout
|
||||
- `web/src/`: web backend + Streamlit app source
|
||||
- `mac/src/`: Xcode macOS shell app (`WKWebView` host)
|
||||
- `scripts/`: build and packaging scripts
|
||||
- `docs/`: architecture and supporting docs
|
||||
- `skills/`: reusable project skills
|
||||
|
||||
## Web Source
|
||||
- `web/src/app.py`: Streamlit entrypoint and UI orchestration
|
||||
- `web/src/web_core/`: strategy/data/chart/export modules
|
||||
- `web/src/requirements.txt`: Python dependencies
|
||||
- `web/src/ONBOARDING.md`: in-app onboarding guide content
|
||||
- `web/src/PRD.md`: web product rules and behavior spec
|
||||
|
||||
## macOS Shell
|
||||
- Project location: `mac/src/` (`*.xcodeproj` auto-discovered by scripts)
|
||||
- Uses `WKWebView` and launches embedded backend executable from app resources.
|
||||
- No external browser required.
|
||||
|
||||
See `mac/src/README.md` for shell details.
|
||||
|
||||
## Setup
|
||||
### Quick start
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Setup only
|
||||
```bash
|
||||
./run.sh --setup-only
|
||||
```
|
||||
|
||||
### Tests
|
||||
```bash
|
||||
make test
|
||||
```
|
||||
|
||||
## Build Self-Contained macOS App
|
||||
```bash
|
||||
./scripts/build_selfcontained_mac_app.sh
|
||||
```
|
||||
Output: `dist-mac/<timestamp>/<Scheme>.app`
|
||||
|
||||
Package as DMG:
|
||||
```bash
|
||||
APP_BUNDLE_PATH="dist-mac/<timestamp>/<Scheme>.app" ./scripts/create_installer_dmg.sh
|
||||
```
|
||||
Output: `build/dmg/<AppName>-<timestamp>.dmg`
|
||||
|
||||
## Optional Standalone Streamlit App
|
||||
```bash
|
||||
./scripts/build_standalone_app.sh
|
||||
```
|
||||
Output: `dist-standalone/<timestamp>/dist/<RepoName>.app`
|
||||
|
||||
## Notes
|
||||
- Analysis-only app; no trade execution.
|
||||
- Yahoo Finance interval availability depends on symbol/lookback.
|
||||
- For broad distribution, use code signing + notarization.
|
||||
5
Run App.command
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
./run.sh
|
||||
391
deploy_synology.sh
Executable file
@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${BASH_VERSION:-}" ]]; then
|
||||
echo "Error: run this script with bash (example: bash deploy_synology.sh)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ManeshTrader quick deploy helper for Synology Docker host.
|
||||
# Defaults are set for the target provided by the user.
|
||||
|
||||
ENV_FILE=".env.deploy_synology"
|
||||
if [[ -f "$ENV_FILE" ]]; then
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
fi
|
||||
|
||||
REMOTE_USER="${REMOTE_USER:-mbrucedogs}"
|
||||
REMOTE_HOST="${REMOTE_HOST:-192.168.1.128}"
|
||||
REMOTE_PORT="${REMOTE_PORT:-25}"
|
||||
REMOTE_BASE="${REMOTE_BASE:-/volume1/docker/maneshtrader}"
|
||||
LOCAL_BASE="${LOCAL_BASE:-web/src}"
|
||||
SYNO_PASSWORD="${SYNO_PASSWORD:-}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-}"
|
||||
SUDO_PASSWORD="${SUDO_PASSWORD:-${SYNO_PASSWORD:-}}"
|
||||
SUDO_MODE="${SUDO_MODE:-auto}" # auto|always|never
|
||||
SSH_PASS_WARNING_SHOWN="0"
|
||||
|
||||
MODE="bind" # bind: upload + restart; image: upload + rebuild
|
||||
NO_RESTART="0"
|
||||
DRY_RUN="0"
|
||||
|
||||
DEFAULT_FILES=(
|
||||
"web/src/web_core/ui/help_content.py"
|
||||
"web/src/PRD.md"
|
||||
"web/src/ONBOARDING.md"
|
||||
)
|
||||
|
||||
FILES=("${DEFAULT_FILES[@]}")
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./deploy_synology.sh [options]
|
||||
|
||||
Options:
|
||||
--mode bind|image Deploy mode (default: bind)
|
||||
--files "a b c" Space-separated file list to upload
|
||||
--recent-minutes N Upload files under web/src modified in last N minutes
|
||||
--git-hours N Upload files changed in last N hours (git history + working tree)
|
||||
--env-file PATH Load environment variables from file (default: .env.deploy_synology if present)
|
||||
--sudo-mode MODE Docker privilege mode: auto|always|never (default: auto)
|
||||
--no-restart Upload only; skip remote docker restart/rebuild
|
||||
--dry-run Print actions but do not execute
|
||||
-h, --help Show help
|
||||
|
||||
Environment overrides:
|
||||
REMOTE_USER, REMOTE_HOST, REMOTE_PORT, REMOTE_BASE, LOCAL_BASE, SYNO_PASSWORD, CONTAINER_NAME,
|
||||
SUDO_PASSWORD, SUDO_MODE
|
||||
|
||||
Examples:
|
||||
./deploy_synology.sh
|
||||
./deploy_synology.sh --mode image
|
||||
./deploy_synology.sh --recent-minutes 120
|
||||
./deploy_synology.sh --git-hours 2
|
||||
./deploy_synology.sh --env-file .env.deploy_synology --git-hours 2
|
||||
./deploy_synology.sh --git-hours 2 --sudo-mode always
|
||||
./deploy_synology.sh --files "web/src/web_core/ui/help_content.py"
|
||||
EOF
|
||||
}
|
||||
|
||||
run_cmd() {
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
printf '[dry-run] %s\n' "$*"
|
||||
else
|
||||
eval "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
build_ssh_cmd() {
|
||||
if [[ -n "${SYNO_PASSWORD:-}" ]]; then
|
||||
if ! command -v sshpass >/dev/null 2>&1; then
|
||||
if [[ "$SSH_PASS_WARNING_SHOWN" == "0" ]]; then
|
||||
echo "Warning: SYNO_PASSWORD is set but sshpass is not installed; falling back to interactive ssh/scp prompts." >&2
|
||||
SSH_PASS_WARNING_SHOWN="1"
|
||||
fi
|
||||
printf "ssh -p '%s' '%s@%s'" "${REMOTE_PORT}" "${REMOTE_USER}" "${REMOTE_HOST}"
|
||||
return
|
||||
fi
|
||||
printf "SSHPASS='%s' sshpass -e ssh -o StrictHostKeyChecking=accept-new -p '%s' '%s@%s'" \
|
||||
"${SYNO_PASSWORD}" "${REMOTE_PORT}" "${REMOTE_USER}" "${REMOTE_HOST}"
|
||||
else
|
||||
printf "ssh -p '%s' '%s@%s'" "${REMOTE_PORT}" "${REMOTE_USER}" "${REMOTE_HOST}"
|
||||
fi
|
||||
}
|
||||
|
||||
build_scp_cmd() {
|
||||
if [[ -n "${SYNO_PASSWORD:-}" ]]; then
|
||||
if ! command -v sshpass >/dev/null 2>&1; then
|
||||
if [[ "$SSH_PASS_WARNING_SHOWN" == "0" ]]; then
|
||||
echo "Warning: SYNO_PASSWORD is set but sshpass is not installed; falling back to interactive ssh/scp prompts." >&2
|
||||
SSH_PASS_WARNING_SHOWN="1"
|
||||
fi
|
||||
printf "scp -O -P '%s'" "${REMOTE_PORT}"
|
||||
return
|
||||
fi
|
||||
printf "SSHPASS='%s' sshpass -e scp -O -o StrictHostKeyChecking=accept-new -P '%s'" \
|
||||
"${SYNO_PASSWORD}" "${REMOTE_PORT}"
|
||||
else
|
||||
printf "scp -O -P '%s'" "${REMOTE_PORT}"
|
||||
fi
|
||||
}
|
||||
|
||||
to_remote_rel_path() {
|
||||
local file="$1"
|
||||
local prefix="${LOCAL_BASE%/}/"
|
||||
if [[ "$file" == "$prefix"* ]]; then
|
||||
printf "%s" "${file#$prefix}"
|
||||
else
|
||||
printf "%s" "$file"
|
||||
fi
|
||||
}
|
||||
|
||||
collect_git_hours_files() {
|
||||
local hours="$1"
|
||||
local base_commit
|
||||
local combined
|
||||
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
echo "Error: git is required for --git-hours." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "Error: --git-hours must be run from inside a git repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
base_commit="$(git rev-list -n 1 --before="${hours} hours ago" HEAD 2>/dev/null || true)"
|
||||
|
||||
combined="$(
|
||||
{
|
||||
if [[ -n "$base_commit" ]]; then
|
||||
git diff --name-only --diff-filter=ACMRTUXB "${base_commit}..HEAD" -- web/src
|
||||
fi
|
||||
git diff --name-only -- web/src
|
||||
git diff --cached --name-only -- web/src
|
||||
git ls-files -m -o --exclude-standard -- web/src
|
||||
} | sed '/^$/d' | sort -u
|
||||
)"
|
||||
|
||||
FILES=()
|
||||
while IFS= read -r candidate; do
|
||||
[[ -z "$candidate" ]] && continue
|
||||
FILES+=("$candidate")
|
||||
done <<EOF
|
||||
$combined
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--env-file)
|
||||
ENV_FILE="${2:-}"
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
echo "Error: env file not found: $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
# shellcheck disable=SC1090
|
||||
set -a; source "$ENV_FILE"; set +a
|
||||
REMOTE_USER="${REMOTE_USER:-mbrucedogs}"
|
||||
REMOTE_HOST="${REMOTE_HOST:-192.168.1.128}"
|
||||
REMOTE_PORT="${REMOTE_PORT:-25}"
|
||||
REMOTE_BASE="${REMOTE_BASE:-/volume1/docker/maneshtrader}"
|
||||
LOCAL_BASE="${LOCAL_BASE:-web/src}"
|
||||
SYNO_PASSWORD="${SYNO_PASSWORD:-}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-}"
|
||||
SUDO_PASSWORD="${SUDO_PASSWORD:-${SYNO_PASSWORD:-}}"
|
||||
SUDO_MODE="${SUDO_MODE:-auto}"
|
||||
shift 2
|
||||
;;
|
||||
--files)
|
||||
read -r -a FILES <<< "${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--recent-minutes)
|
||||
minutes="${2:-}"
|
||||
if ! [[ "$minutes" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --recent-minutes requires an integer." >&2
|
||||
exit 1
|
||||
fi
|
||||
FILES=()
|
||||
while IFS= read -r candidate; do
|
||||
[[ -z "$candidate" ]] && continue
|
||||
FILES+=("$candidate")
|
||||
done <<EOF
|
||||
$(find web/src -type f -mmin "-$minutes" ! -name ".DS_Store" | sort)
|
||||
EOF
|
||||
shift 2
|
||||
;;
|
||||
--git-hours)
|
||||
hours="${2:-}"
|
||||
if ! [[ "$hours" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --git-hours requires an integer." >&2
|
||||
exit 1
|
||||
fi
|
||||
collect_git_hours_files "$hours"
|
||||
shift 2
|
||||
;;
|
||||
--no-restart)
|
||||
NO_RESTART="1"
|
||||
shift
|
||||
;;
|
||||
--sudo-mode)
|
||||
SUDO_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN="1"
|
||||
shift
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "$MODE" != "bind" && "$MODE" != "image" ]]; then
|
||||
echo "Error: --mode must be 'bind' or 'image'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$SUDO_MODE" != "auto" && "$SUDO_MODE" != "always" && "$SUDO_MODE" != "never" ]]; then
|
||||
echo "Error: --sudo-mode must be auto|always|never." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
echo "Error: No files selected for upload." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
if [[ ! -f "$file" ]]; then
|
||||
echo "Error: Local file not found: $file" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Deploy target: ${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_PORT}"
|
||||
echo "Remote base: ${REMOTE_BASE}"
|
||||
echo "Mode: ${MODE}"
|
||||
echo "Files:"
|
||||
for file in "${FILES[@]}"; do
|
||||
echo " - $file"
|
||||
done
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
remote_rel="$(to_remote_rel_path "$file")"
|
||||
remote_path="${REMOTE_BASE}/${remote_rel}"
|
||||
remote_dir="$(dirname "$remote_path")"
|
||||
SSH_CMD="$(build_ssh_cmd)"
|
||||
SCP_CMD="$(build_scp_cmd)"
|
||||
run_cmd "${SSH_CMD} \"mkdir -p '${remote_dir}'\""
|
||||
run_cmd "${SCP_CMD} '${file}' '${REMOTE_USER}@${REMOTE_HOST}:${remote_path}'"
|
||||
done
|
||||
|
||||
if [[ "$NO_RESTART" == "1" ]]; then
|
||||
echo "Upload complete (restart/rebuild skipped)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ "$MODE" == "bind" ]]; then
|
||||
SSH_CMD="$(build_ssh_cmd)"
|
||||
run_cmd "${SSH_CMD} '
|
||||
set -e
|
||||
cd \"${REMOTE_BASE}\"
|
||||
SUDO_MODE=\"${SUDO_MODE}\"
|
||||
SUDO_PASSWORD=\"${SUDO_PASSWORD}\"
|
||||
DOCKER_BIN=\"\"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
DOCKER_BIN=\"\$(command -v docker)\"
|
||||
elif [[ -x /usr/local/bin/docker ]]; then
|
||||
DOCKER_BIN=\"/usr/local/bin/docker\"
|
||||
elif [[ -x /usr/bin/docker ]]; then
|
||||
DOCKER_BIN=\"/usr/bin/docker\"
|
||||
elif [[ -x /var/packages/ContainerManager/target/usr/bin/docker ]]; then
|
||||
DOCKER_BIN=\"/var/packages/ContainerManager/target/usr/bin/docker\"
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [[ \"\$USE_SUDO\" == \"1\" ]]; then
|
||||
if [[ -n \"\$SUDO_PASSWORD\" ]]; then
|
||||
printf \"%s\\n\" \"\$SUDO_PASSWORD\" | sudo -S -p \"\" \"\$DOCKER_BIN\" \"\$@\"
|
||||
else
|
||||
sudo \"\$DOCKER_BIN\" \"\$@\"
|
||||
fi
|
||||
else
|
||||
\"\$DOCKER_BIN\" \"\$@\"
|
||||
fi
|
||||
}
|
||||
|
||||
USE_SUDO=\"0\"
|
||||
if [[ \"\$SUDO_MODE\" == \"always\" ]]; then
|
||||
USE_SUDO=\"1\"
|
||||
elif [[ \"\$SUDO_MODE\" == \"auto\" ]]; then
|
||||
if ! \"\$DOCKER_BIN\" info >/dev/null 2>&1; then
|
||||
USE_SUDO=\"1\"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n \"\$DOCKER_BIN\" ]] && docker_cmd compose version >/dev/null 2>&1; then
|
||||
docker_cmd compose restart
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose restart
|
||||
elif [[ -n \"\$DOCKER_BIN\" ]]; then
|
||||
if [[ -n \"${CONTAINER_NAME:-}\" ]]; then
|
||||
docker_cmd restart \"${CONTAINER_NAME}\"
|
||||
else
|
||||
ids=\$(docker_cmd ps --filter \"name=maneshtrader\" -q)
|
||||
if [[ -z \"\$ids\" ]]; then
|
||||
echo \"No docker compose command found, and no running containers matched name=maneshtrader.\" >&2
|
||||
echo \"Set CONTAINER_NAME=<your-container-name> and rerun.\" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker_cmd restart \$ids
|
||||
fi
|
||||
else
|
||||
echo \"No docker or docker compose command found on remote host.\" >&2
|
||||
exit 1
|
||||
fi
|
||||
'"
|
||||
echo "Upload + container restart complete."
|
||||
else
|
||||
SSH_CMD="$(build_ssh_cmd)"
|
||||
run_cmd "${SSH_CMD} '
|
||||
set -e
|
||||
cd \"${REMOTE_BASE}\"
|
||||
SUDO_MODE=\"${SUDO_MODE}\"
|
||||
SUDO_PASSWORD=\"${SUDO_PASSWORD}\"
|
||||
DOCKER_BIN=\"\"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
DOCKER_BIN=\"\$(command -v docker)\"
|
||||
elif [[ -x /usr/local/bin/docker ]]; then
|
||||
DOCKER_BIN=\"/usr/local/bin/docker\"
|
||||
elif [[ -x /usr/bin/docker ]]; then
|
||||
DOCKER_BIN=\"/usr/bin/docker\"
|
||||
elif [[ -x /var/packages/ContainerManager/target/usr/bin/docker ]]; then
|
||||
DOCKER_BIN=\"/var/packages/ContainerManager/target/usr/bin/docker\"
|
||||
fi
|
||||
|
||||
docker_cmd() {
|
||||
if [[ \"\$USE_SUDO\" == \"1\" ]]; then
|
||||
if [[ -n \"\$SUDO_PASSWORD\" ]]; then
|
||||
printf \"%s\\n\" \"\$SUDO_PASSWORD\" | sudo -S -p \"\" \"\$DOCKER_BIN\" \"\$@\"
|
||||
else
|
||||
sudo \"\$DOCKER_BIN\" \"\$@\"
|
||||
fi
|
||||
else
|
||||
\"\$DOCKER_BIN\" \"\$@\"
|
||||
fi
|
||||
}
|
||||
|
||||
USE_SUDO=\"0\"
|
||||
if [[ \"\$SUDO_MODE\" == \"always\" ]]; then
|
||||
USE_SUDO=\"1\"
|
||||
elif [[ \"\$SUDO_MODE\" == \"auto\" ]]; then
|
||||
if ! \"\$DOCKER_BIN\" info >/dev/null 2>&1; then
|
||||
USE_SUDO=\"1\"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -n \"\$DOCKER_BIN\" ]] && docker_cmd compose version >/dev/null 2>&1; then
|
||||
docker_cmd compose up -d --build
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
docker-compose up -d --build
|
||||
else
|
||||
echo \"No docker compose command found on remote host.\" >&2
|
||||
exit 1
|
||||
fi
|
||||
'"
|
||||
echo "Upload + image rebuild complete."
|
||||
fi
|
||||
@ -1,26 +0,0 @@
|
||||
services:
|
||||
maneshtrader:
|
||||
image: maneshtrader:latest
|
||||
pull_policy: build
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${APP_PORT:-8501}:8501"
|
||||
environment:
|
||||
PYTHONDONTWRITEBYTECODE: "1"
|
||||
PYTHONUNBUFFERED: "1"
|
||||
PIP_NO_CACHE_DIR: "1"
|
||||
STREAMLIT_BROWSER_GATHER_USAGE_STATS: "false"
|
||||
volumes:
|
||||
- maneshtrader_data:/root/.web_local_shell
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://127.0.0.1:8501/_stcore/health', timeout=3)\" || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 45s
|
||||
|
||||
volumes:
|
||||
maneshtrader_data:
|
||||
345
docs/architecture.md
Normal file
@ -0,0 +1,345 @@
|
||||
# ManeshTrader - Architecture Plan
|
||||
|
||||
## Executive Summary
|
||||
ManeshTrader is an analysis-only trading intelligence system that classifies OHLC bars into real/fake signals, derives trend state from real bars, and delivers visual insights, exports, and optional monitoring alerts. The architecture refactor moves from a single-process UI-first design to a modular, service-oriented design that improves testability, scalability, observability, and operational resilience.
|
||||
|
||||
## System Context
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Trader[Trader / Analyst] -->|Configure symbol, timeframe, filters| ManeshTrader[ManeshTrader Analysis Platform]
|
||||
ManeshTrader -->|Fetch OHLC| Yahoo[Yahoo Finance Data API]
|
||||
ManeshTrader -->|Optional notifications| Notify[Email / Push / Webhook]
|
||||
ManeshTrader -->|Export CSV/PDF| Storage[(User Downloads / Object Storage)]
|
||||
Admin[Operator] -->|Monitor health, logs, metrics| ManeshTrader
|
||||
```
|
||||
|
||||
Overview: Defines external actors and dependencies around ManeshTrader.
|
||||
|
||||
Key Components: Trader, Operator, data provider, notification endpoints, export targets.
|
||||
|
||||
Relationships: Traders submit analysis requests; platform retrieves market data, computes classifications/trends, and returns charted/exported output.
|
||||
|
||||
Design Decisions: Keep execution out-of-scope (analysis-only boundary). External market source decoupled behind adapter interface.
|
||||
|
||||
NFR Considerations:
|
||||
- Scalability: Stateless analysis workloads can scale horizontally.
|
||||
- Performance: Cached market data and precomputed features reduce latency.
|
||||
- Security: Read-only market data; no brokerage keys for execution.
|
||||
- Reliability: Retry/fallback on upstream data issues.
|
||||
- Maintainability: Clear system boundary reduces coupling.
|
||||
|
||||
Trade-offs: Dependency on third-party data availability/quality.
|
||||
|
||||
Risks and Mitigations:
|
||||
- Risk: API throttling/outage. Mitigation: caching, backoff, alternate provider adapter.
|
||||
|
||||
## Architecture Overview
|
||||
The target architecture uses modular services with explicit boundaries:
|
||||
- Presentation: Web UI / API consumers
|
||||
- Application: Strategy orchestration and workflow
|
||||
- Domain: Bar classification and trend state machine
|
||||
- Data: Provider adapters, cache, persistence
|
||||
- Platform: auth, observability, notifications, exports
|
||||
|
||||
## Component Architecture
|
||||
```mermaid
|
||||
flowchart TB
|
||||
UI[Web UI / Streamlit Frontend]
|
||||
API[Analysis API Gateway]
|
||||
ORCH[Analysis Orchestrator]
|
||||
STRAT[Strategy Engine
|
||||
(Real/Fake Classifier + Trend State Machine)]
|
||||
BT[Backtest Evaluator]
|
||||
ALERT[Alerting Service]
|
||||
EXPORT[Export Service
|
||||
(CSV/PDF)]
|
||||
MKT[Market Data Adapter]
|
||||
CACHE[(Market Cache)]
|
||||
DB[(Analysis Store)]
|
||||
OBS[Observability
|
||||
Logs/Metrics/Traces]
|
||||
|
||||
UI --> API
|
||||
API --> ORCH
|
||||
ORCH --> STRAT
|
||||
ORCH --> BT
|
||||
ORCH --> ALERT
|
||||
ORCH --> EXPORT
|
||||
ORCH --> MKT
|
||||
MKT --> CACHE
|
||||
MKT --> ORCH
|
||||
ORCH --> DB
|
||||
API --> DB
|
||||
API --> OBS
|
||||
ORCH --> OBS
|
||||
STRAT --> OBS
|
||||
ALERT --> OBS
|
||||
```
|
||||
|
||||
Overview: Internal modular decomposition for the refactored system.
|
||||
|
||||
Key Components:
|
||||
- Analysis API Gateway: request validation and rate limiting.
|
||||
- Analysis Orchestrator: coordinates data fetch, strategy execution, and response assembly.
|
||||
- Strategy Engine: deterministic classification and trend transitions.
|
||||
- Backtest Evaluator: lightweight historical scoring.
|
||||
- Alerting/Export Services: asynchronous side effects.
|
||||
- Market Data Adapter + Cache: provider abstraction and performance buffer.
|
||||
|
||||
Relationships: API delegates to orchestrator; orchestrator composes domain and side-effect services; observability is cross-cutting.
|
||||
|
||||
Design Decisions: Separate deterministic core logic from IO-heavy integrations.
|
||||
|
||||
NFR Considerations:
|
||||
- Scalability: independent scaling for API, strategy workers, and alert/export processors.
|
||||
- Performance: cache first, compute second, persist last.
|
||||
- Security: centralized input validation and API policy.
|
||||
- Reliability: queue-based side effects isolate failures.
|
||||
- Maintainability: single-responsibility services and clear contracts.
|
||||
|
||||
Trade-offs: More infrastructure and operational complexity than monolith.
|
||||
|
||||
Risks and Mitigations:
|
||||
- Risk: distributed debugging complexity. Mitigation: trace IDs and structured logs.
|
||||
|
||||
## Deployment Architecture
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Internet
|
||||
U[User Browser]
|
||||
end
|
||||
|
||||
subgraph Cloud[VPC]
|
||||
LB[HTTPS Load Balancer / WAF]
|
||||
subgraph AppSubnet[Private App Subnet]
|
||||
UI[UI Service]
|
||||
API[API Service]
|
||||
WRK[Worker Service]
|
||||
end
|
||||
subgraph DataSubnet[Private Data Subnet]
|
||||
REDIS[(Redis Cache)]
|
||||
PG[(PostgreSQL)]
|
||||
MQ[(Message Queue)]
|
||||
OBJ[(Object Storage)]
|
||||
end
|
||||
OBS[Managed Monitoring]
|
||||
SECRET[Secrets Manager]
|
||||
end
|
||||
|
||||
EXT[Yahoo Finance / Alt Provider]
|
||||
|
||||
U --> LB --> UI
|
||||
UI --> API
|
||||
API --> REDIS
|
||||
API --> PG
|
||||
API --> MQ
|
||||
WRK --> MQ
|
||||
WRK --> PG
|
||||
WRK --> OBJ
|
||||
API --> EXT
|
||||
WRK --> EXT
|
||||
API --> OBS
|
||||
WRK --> OBS
|
||||
API --> SECRET
|
||||
WRK --> SECRET
|
||||
```
|
||||
|
||||
Overview: Logical production deployment with secure segmentation.
|
||||
|
||||
Key Components: WAF/LB edge, stateless app services, queue-driven workers, managed data stores.
|
||||
|
||||
Relationships: Request path stays synchronous through UI/API; heavy export/alert tasks handled asynchronously by workers.
|
||||
|
||||
Design Decisions: Network segmentation and managed services for resilience and lower ops overhead.
|
||||
|
||||
NFR Considerations:
|
||||
- Scalability: autoscaling app and worker tiers.
|
||||
- Performance: Redis cache and async task offload.
|
||||
- Security: private subnets, secret manager, TLS at edge.
|
||||
- Reliability: managed DB backup, queue durability.
|
||||
- Maintainability: environment parity across dev/staging/prod.
|
||||
|
||||
Trade-offs: Managed services cost vs operational simplicity.
|
||||
|
||||
Risks and Mitigations:
|
||||
- Risk: queue backlog under spikes. Mitigation: autoscaling workers and dead-letter queues.
|
||||
|
||||
## Data Flow
|
||||
```mermaid
|
||||
flowchart LR
|
||||
R[User Request
|
||||
symbol/timeframe/filters] --> V[Input Validation]
|
||||
V --> C1{Cache Hit?}
|
||||
C1 -->|Yes| D1[Load OHLC from Cache]
|
||||
C1 -->|No| D2[Fetch OHLC from Provider]
|
||||
D2 --> D3[Normalize + Time Alignment]
|
||||
D3 --> D4[Persist to Cache]
|
||||
D1 --> P1[Classify Bars
|
||||
real_bull/real_bear/fake]
|
||||
D4 --> P1
|
||||
P1 --> P2[Trend State Machine
|
||||
2-bar confirmation]
|
||||
P2 --> P3[Backtest Snapshot]
|
||||
P3 --> P4[Build Response Model]
|
||||
P4 --> S1[(Analysis Store)]
|
||||
P4 --> O[UI Chart + Metrics + Events]
|
||||
P4 --> E[Export Job CSV/PDF]
|
||||
P4 --> A[Alert Job]
|
||||
```
|
||||
|
||||
Overview: End-to-end data processing lifecycle for each analysis request.
|
||||
|
||||
Key Components: validation, cache/provider ingestion, classification/trend processing, output assembly.
|
||||
|
||||
Relationships: deterministic analytics pipeline with optional async exports/alerts.
|
||||
|
||||
Design Decisions: Normalize data before strategy logic for deterministic outcomes.
|
||||
|
||||
NFR Considerations:
|
||||
- Scalability: compute pipeline can be parallelized per request.
|
||||
- Performance: cache avoids repeated provider calls.
|
||||
- Security: strict input schema and output sanitization.
|
||||
- Reliability: idempotent processing and persisted analysis snapshots.
|
||||
- Maintainability: explicit stage boundaries simplify test coverage.
|
||||
|
||||
Trade-offs: Additional persistence adds write latency.
|
||||
|
||||
Risks and Mitigations:
|
||||
- Risk: inconsistent timestamps across providers. Mitigation: canonical UTC normalization.
|
||||
|
||||
## Key Workflows
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant UI
|
||||
participant API
|
||||
participant Data as Market Adapter
|
||||
participant Engine as Strategy Engine
|
||||
participant Alert as Alert Service
|
||||
participant Export as Export Service
|
||||
|
||||
User->>UI: Submit symbol/timeframe/filters
|
||||
UI->>API: Analyze request
|
||||
API->>Data: Get closed OHLC bars
|
||||
Data-->>API: Normalized bars
|
||||
API->>Engine: Classify + detect trend
|
||||
Engine-->>API: trend state, events, metrics
|
||||
API-->>UI: chart model + events + backtest
|
||||
alt New trend/reversal event
|
||||
API->>Alert: Publish notification task
|
||||
end
|
||||
opt User requests export
|
||||
UI->>API: Export CSV/PDF
|
||||
API->>Export: Generate artifact
|
||||
Export-->>UI: Download link/blob
|
||||
end
|
||||
```
|
||||
|
||||
Overview: Critical user flow from request to insight/alert/export.
|
||||
|
||||
Key Components: UI, API, data adapter, strategy engine, alert/export services.
|
||||
|
||||
Relationships: synchronous analysis, asynchronous side effects.
|
||||
|
||||
Design Decisions: Keep analytical response fast; move non-critical tasks to background.
|
||||
|
||||
NFR Considerations:
|
||||
- Scalability: alert/export can scale separately.
|
||||
- Performance: response prioritizes analysis payload.
|
||||
- Security: permission checks around export and notification endpoints.
|
||||
- Reliability: retries for failed async tasks.
|
||||
- Maintainability: workflow contracts versioned via API schema.
|
||||
|
||||
Trade-offs: eventual consistency for async outputs.
|
||||
|
||||
Risks and Mitigations:
|
||||
- Risk: duplicate alerts after retries. Mitigation: idempotency keys by event hash.
|
||||
|
||||
## Additional Diagram: Domain State Model
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Neutral
|
||||
Neutral --> Bullish: 2 consecutive real_bull
|
||||
Neutral --> Bearish: 2 consecutive real_bear
|
||||
Bullish --> Bullish: fake OR single real_bear fluke
|
||||
Bearish --> Bearish: fake OR single real_bull fluke
|
||||
Bullish --> Bearish: 2 consecutive real_bear
|
||||
Bearish --> Bullish: 2 consecutive real_bull
|
||||
```
|
||||
|
||||
Overview: Strategy state transitions based on confirmed real-bar sequences.
|
||||
|
||||
Key Components: Neutral, Bullish, Bearish states; confirmation conditions.
|
||||
|
||||
Relationships: fake bars never reverse state; opposite single bar is non-reversal noise.
|
||||
|
||||
Design Decisions: enforce confirmation to reduce whipsaw.
|
||||
|
||||
NFR Considerations:
|
||||
- Scalability: pure function state machine enables easy horizontal compute.
|
||||
- Performance: O(n) per bar sequence.
|
||||
- Security: deterministic logic reduces ambiguity and operator error.
|
||||
- Reliability: explicit transitions avoid hidden side effects.
|
||||
- Maintainability: state model is test-friendly and auditable.
|
||||
|
||||
Trade-offs: delayed reversals in fast inflection markets.
|
||||
|
||||
Risks and Mitigations:
|
||||
- Risk: late entries due to confirmation lag. Mitigation: optional “early warning” non-trading signal.
|
||||
|
||||
## Phased Development
|
||||
|
||||
### Phase 1: Initial Implementation
|
||||
- Single deployable web app with embedded analysis module.
|
||||
- Basic data adapter, core strategy engine, charting, CSV export.
|
||||
- Local logs and lightweight metrics.
|
||||
|
||||
### Phase 2+: Final Architecture
|
||||
- Split API, workers, and dedicated alert/export services.
|
||||
- Add cache + persistent analysis store + queue-driven async tasks.
|
||||
- Multi-provider market adapter and hardened observability.
|
||||
|
||||
### Migration Path
|
||||
1. Extract strategy logic into standalone domain module with unit tests.
|
||||
2. Introduce API boundary and typed request/response contracts.
|
||||
3. Externalize side effects (alerts/exports) into worker queue.
|
||||
4. Add Redis caching and persistent analysis snapshots.
|
||||
5. Enable multi-environment CI/CD and infrastructure-as-code.
|
||||
|
||||
## Non-Functional Requirements Analysis
|
||||
|
||||
### Scalability
|
||||
Stateless API/services with autoscaling; async workers for bursty jobs; provider/caching abstraction to reduce upstream load.
|
||||
|
||||
### Performance
|
||||
Cache-first ingestion, bounded bar windows, O(n) classification/state processing, deferred heavy exports.
|
||||
|
||||
### Security
|
||||
WAF/TLS, secrets manager, strict request validation, RBAC for admin controls, audit logs for alert/export actions.
|
||||
|
||||
### Reliability
|
||||
Queue retry policies, dead-letter queues, health probes, circuit breakers for upstream data sources, backup/restore for persistent stores.
|
||||
|
||||
### Maintainability
|
||||
Layered architecture, clear contracts, domain isolation, test pyramid (unit/contract/integration), observability-first operations.
|
||||
|
||||
## Risks and Mitigations
|
||||
- Upstream data inconsistency: normalize timestamps and schema at adapter boundary.
|
||||
- Alert noise: debounce and idempotency keyed by symbol/timeframe/event timestamp.
|
||||
- Cost growth with scale: autoscaling guardrails, TTL caches, export retention policy.
|
||||
- Strategy misinterpretation: publish explicit strategy rules and state transition docs in product UI.
|
||||
|
||||
## Technology Stack Recommendations
|
||||
- Frontend: Streamlit (MVP) then React/Next.js for multi-user production UX.
|
||||
- API: FastAPI with pydantic contracts.
|
||||
- Workers: Celery/RQ with Redis or managed queue.
|
||||
- Storage: PostgreSQL for analysis metadata; object storage for export artifacts.
|
||||
- Observability: OpenTelemetry + managed logging/metrics dashboards.
|
||||
- Deployment: Containerized services on managed Kubernetes or serverless containers.
|
||||
|
||||
## Next Steps
|
||||
1. Approve the phased architecture and target operating model.
|
||||
2. Define API contracts for analysis request/response and event schema.
|
||||
3. Implement Phase 1 module boundaries (domain/application/infrastructure).
|
||||
4. Add core test suite for classification and trend state transitions.
|
||||
5. Plan Phase 2 service split and infrastructure rollout.
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
68
mac/src/App/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@ -0,0 +1,68 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 26 KiB |
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png
Normal file
|
After Width: | Height: | Size: 792 B |
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 68 KiB |
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
mac/src/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png
Normal file
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 76 KiB |
6
mac/src/App/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
867
mac/src/App/ContentView.swift
Normal file
@ -0,0 +1,867 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct ContentView: View {
|
||||
@State private var host = TraderHost()
|
||||
@State private var didAutostart = false
|
||||
@AppStorage("mt_setup_completed") private var setupCompleted = false
|
||||
@AppStorage("mt_symbol") private var storedSymbol = "AAPL"
|
||||
@AppStorage("mt_interval") private var storedInterval = "1d"
|
||||
@AppStorage("mt_period") private var storedPeriod = "6mo"
|
||||
@AppStorage("mt_max_bars") private var storedMaxBars = 500
|
||||
@AppStorage("mt_drop_live") private var storedDropLive = true
|
||||
@AppStorage("mt_use_body_range") private var storedUseBodyRange = false
|
||||
@AppStorage("mt_volume_filter_enabled") private var storedVolumeFilterEnabled = false
|
||||
@AppStorage("mt_volume_sma_window") private var storedVolumeSMAWindow = 20
|
||||
@AppStorage("mt_volume_multiplier") private var storedVolumeMultiplier = 1.0
|
||||
@AppStorage("mt_gray_fake") private var storedGrayFake = true
|
||||
@AppStorage("mt_hide_market_closed_gaps") private var storedHideMarketClosedGaps = true
|
||||
@AppStorage("mt_enable_auto_refresh") private var storedEnableAutoRefresh = false
|
||||
@AppStorage("mt_refresh_sec") private var storedRefreshSeconds = 60
|
||||
@State private var showSetupSheet = false
|
||||
@State private var showHelpSheet = false
|
||||
@State private var setupDraft = UserSetupPreferences.default
|
||||
#if DEBUG
|
||||
@State private var showDebugPanel = false
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if host.isRunning {
|
||||
LocalWebView(url: host.serverURL, reloadToken: host.reloadToken)
|
||||
} else {
|
||||
launchView
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
debugPanel
|
||||
#endif
|
||||
}
|
||||
.frame(minWidth: 1100, minHeight: 760)
|
||||
.onAppear {
|
||||
guard !didAutostart else { return }
|
||||
didAutostart = true
|
||||
let normalized = syncHostPreferencesFromSharedSettings()
|
||||
persistSharedSettingsFile(normalized)
|
||||
host.start()
|
||||
|
||||
if !setupCompleted {
|
||||
setupDraft = normalized.setupDefaults
|
||||
showSetupSheet = true
|
||||
}
|
||||
}
|
||||
.onDisappear { host.stop() }
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
Button("Help") {
|
||||
showHelpSheet = true
|
||||
}
|
||||
Button("Setup") {
|
||||
syncStateFromSharedSettingsFileIfAvailable()
|
||||
setupDraft = storedWebPreferences.normalized().setupDefaults
|
||||
showSetupSheet = true
|
||||
}
|
||||
Button("Reload") {
|
||||
_ = syncHostPreferencesFromSharedSettings()
|
||||
host.reloadWebView()
|
||||
}
|
||||
.disabled(!host.isRunning)
|
||||
Button("Restart") {
|
||||
_ = syncHostPreferencesFromSharedSettings()
|
||||
host.restart()
|
||||
}
|
||||
.disabled(host.isStarting)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSetupSheet) {
|
||||
setupSheet
|
||||
}
|
||||
.sheet(isPresented: $showHelpSheet) {
|
||||
HelpSheetView()
|
||||
}
|
||||
}
|
||||
|
||||
private var sharedSettingsURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent(".manesh_trader", isDirectory: true)
|
||||
.appendingPathComponent("settings.json", isDirectory: false)
|
||||
}
|
||||
|
||||
private var storedWebPreferences: WebPreferences {
|
||||
WebPreferences(
|
||||
symbol: storedSymbol,
|
||||
interval: storedInterval,
|
||||
period: storedPeriod,
|
||||
maxBars: storedMaxBars,
|
||||
dropLive: storedDropLive,
|
||||
useBodyRange: storedUseBodyRange,
|
||||
volumeFilterEnabled: storedVolumeFilterEnabled,
|
||||
volumeSMAWindow: storedVolumeSMAWindow,
|
||||
volumeMultiplier: storedVolumeMultiplier,
|
||||
grayFake: storedGrayFake,
|
||||
hideMarketClosedGaps: storedHideMarketClosedGaps,
|
||||
enableAutoRefresh: storedEnableAutoRefresh,
|
||||
refreshSeconds: storedRefreshSeconds
|
||||
)
|
||||
}
|
||||
|
||||
private var setupSheet: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text("Choose your default market settings so you see useful data immediately on launch.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Data Defaults") {
|
||||
TextField("Symbol", text: $setupDraft.symbol)
|
||||
Text("Ticker or pair, e.g. AAPL, MSFT, BTC-USD.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("Timeframe", selection: $setupDraft.timeframe) {
|
||||
ForEach(UserSetupPreferences.timeframeOptions, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
Text("Bar size for each candle. `1d` is a good starting point.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("Period", selection: $setupDraft.period) {
|
||||
ForEach(UserSetupPreferences.periodOptions, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
Text("How much history to load for analysis.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Stepper(value: $setupDraft.maxBars, in: 20...5000, step: 10) {
|
||||
Text("Max bars: \(setupDraft.maxBars)")
|
||||
}
|
||||
Text("Limits candles loaded for speed and readability.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.navigationTitle("Welcome to ManeshTrader")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Use Defaults") {
|
||||
let normalized = setupDraft.normalized()
|
||||
applySetup(normalized, markCompleted: true)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save and Continue") {
|
||||
let normalized = setupDraft.normalized()
|
||||
applySetup(normalized, markCompleted: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 560, minHeight: 460)
|
||||
}
|
||||
|
||||
private func applySetup(_ preferences: UserSetupPreferences, markCompleted: Bool) {
|
||||
let normalizedSetup = preferences.normalized()
|
||||
let mergedPreferences = storedWebPreferences
|
||||
.applyingSetup(normalizedSetup)
|
||||
.normalized()
|
||||
writeWebPreferencesToStorage(mergedPreferences)
|
||||
persistSharedSettingsFile(mergedPreferences)
|
||||
if markCompleted {
|
||||
setupCompleted = true
|
||||
}
|
||||
host.applyPreferences(mergedPreferences)
|
||||
host.reloadWebView()
|
||||
showSetupSheet = false
|
||||
}
|
||||
|
||||
private func syncStateFromSharedSettingsFileIfAvailable() {
|
||||
guard
|
||||
let data = try? Data(contentsOf: sharedSettingsURL),
|
||||
let decoded = try? JSONDecoder().decode(WebPreferences.self, from: data)
|
||||
else {
|
||||
return
|
||||
}
|
||||
|
||||
writeWebPreferencesToStorage(decoded.normalized())
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func syncHostPreferencesFromSharedSettings() -> WebPreferences {
|
||||
syncStateFromSharedSettingsFileIfAvailable()
|
||||
let normalized = storedWebPreferences.normalized()
|
||||
writeWebPreferencesToStorage(normalized)
|
||||
host.applyPreferences(normalized)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func writeWebPreferencesToStorage(_ preferences: WebPreferences) {
|
||||
storedSymbol = preferences.symbol
|
||||
storedInterval = preferences.interval
|
||||
storedPeriod = preferences.period
|
||||
storedMaxBars = preferences.maxBars
|
||||
storedDropLive = preferences.dropLive
|
||||
storedUseBodyRange = preferences.useBodyRange
|
||||
storedVolumeFilterEnabled = preferences.volumeFilterEnabled
|
||||
storedVolumeSMAWindow = preferences.volumeSMAWindow
|
||||
storedVolumeMultiplier = preferences.volumeMultiplier
|
||||
storedGrayFake = preferences.grayFake
|
||||
storedHideMarketClosedGaps = preferences.hideMarketClosedGaps
|
||||
storedEnableAutoRefresh = preferences.enableAutoRefresh
|
||||
storedRefreshSeconds = preferences.refreshSeconds
|
||||
}
|
||||
|
||||
private func persistSharedSettingsFile(_ preferences: WebPreferences) {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
|
||||
do {
|
||||
let normalized = preferences.normalized()
|
||||
let directory = sharedSettingsURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let data = try encoder.encode(normalized)
|
||||
try data.write(to: sharedSettingsURL, options: .atomic)
|
||||
} catch {
|
||||
#if DEBUG
|
||||
print("Failed to persist shared settings: \(error.localizedDescription)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private var launchView: some View {
|
||||
VStack(spacing: 14) {
|
||||
launchCard
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(nsColor: .windowBackgroundColor),
|
||||
Color(nsColor: .underPageBackgroundColor),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var launchCard: some View {
|
||||
let card = VStack(spacing: 14) {
|
||||
Image(systemName: host.launchError == nil ? "chart.line.uptrend.xyaxis.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 42))
|
||||
.foregroundStyle(host.launchError == nil ? .green : .orange)
|
||||
|
||||
Text(host.launchError == nil ? "Starting ManeshTrader" : "Couldn’t Start ManeshTrader")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
if let launchError = host.launchError {
|
||||
Text(launchError)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 700)
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
Button("Try Again") { host.start() }
|
||||
.buttonStyle(.glassProminent)
|
||||
} else {
|
||||
Button("Try Again") { host.start() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
|
||||
Text("Loading local engine...")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 26)
|
||||
.padding(.vertical, 22)
|
||||
|
||||
if #available(macOS 26.0, *) {
|
||||
card.glassEffect(.regular.interactive(), in: .rect(cornerRadius: 24))
|
||||
} else {
|
||||
card.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
private var debugPanel: some View {
|
||||
VStack(spacing: 0) {
|
||||
Divider()
|
||||
DisclosureGroup("Developer Tools", isExpanded: $showDebugPanel) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
Button("Start") {
|
||||
_ = syncHostPreferencesFromSharedSettings()
|
||||
host.start()
|
||||
}
|
||||
.disabled(host.isRunning || host.isStarting)
|
||||
Button("Stop") { host.stop() }
|
||||
.disabled(!host.isRunning && !host.isStarting)
|
||||
Button("Reload") {
|
||||
_ = syncHostPreferencesFromSharedSettings()
|
||||
host.reloadWebView()
|
||||
}
|
||||
.disabled(!host.isRunning)
|
||||
}
|
||||
|
||||
Text(host.status)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Local URL: \(host.serverURL.absoluteString)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text("Embedded backend: \(host.backendExecutablePath)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
}
|
||||
.padding(.top, 6)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private struct HelpSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var helpFileURL: URL? {
|
||||
let fm = FileManager.default
|
||||
let candidates = [
|
||||
Bundle.main.url(forResource: "help", withExtension: "html", subdirectory: "Help"),
|
||||
Bundle.main.url(forResource: "help", withExtension: "html"),
|
||||
Bundle.main.resourceURL?.appendingPathComponent("Help/help.html"),
|
||||
Bundle.main.resourceURL?.appendingPathComponent("help.html"),
|
||||
].compactMap { $0 }
|
||||
return candidates.first(where: { fm.fileExists(atPath: $0.path) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let helpFileURL {
|
||||
HelpDocumentWebView(fileURL: helpFileURL)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Help file not found.")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Expected bundled file: help.html")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Help & Quick Start")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(minWidth: 860, minHeight: 640)
|
||||
}
|
||||
}
|
||||
|
||||
private struct HelpDocumentWebView: NSViewRepresentable {
|
||||
let fileURL: URL
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
if webView.url != fileURL {
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ContentView()
|
||||
}
|
||||
|
||||
private struct UserSetupPreferences {
|
||||
static let timeframeOptions = ["1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo"]
|
||||
static let periodOptions = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "max"]
|
||||
|
||||
static let `default` = UserSetupPreferences(symbol: "AAPL", timeframe: "1d", period: "6mo", maxBars: 500)
|
||||
|
||||
var symbol: String
|
||||
var timeframe: String
|
||||
var period: String
|
||||
var maxBars: Int
|
||||
|
||||
func normalized() -> UserSetupPreferences {
|
||||
let normalizedSymbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let safeSymbol = normalizedSymbol.isEmpty ? "AAPL" : normalizedSymbol
|
||||
|
||||
let safeTimeframe = Self.timeframeOptions.contains(timeframe) ? timeframe : "1d"
|
||||
let safePeriod = Self.periodOptions.contains(period) ? period : "6mo"
|
||||
let safeMaxBars = min(5000, max(20, maxBars))
|
||||
|
||||
return UserSetupPreferences(
|
||||
symbol: safeSymbol,
|
||||
timeframe: safeTimeframe,
|
||||
period: safePeriod,
|
||||
maxBars: safeMaxBars
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WebPreferences: Codable {
|
||||
var symbol: String
|
||||
var interval: String
|
||||
var period: String
|
||||
var maxBars: Int
|
||||
var dropLive: Bool
|
||||
var useBodyRange: Bool
|
||||
var volumeFilterEnabled: Bool
|
||||
var volumeSMAWindow: Int
|
||||
var volumeMultiplier: Double
|
||||
var grayFake: Bool
|
||||
var hideMarketClosedGaps: Bool
|
||||
var enableAutoRefresh: Bool
|
||||
var refreshSeconds: Int
|
||||
|
||||
init(
|
||||
symbol: String,
|
||||
interval: String,
|
||||
period: String,
|
||||
maxBars: Int,
|
||||
dropLive: Bool,
|
||||
useBodyRange: Bool,
|
||||
volumeFilterEnabled: Bool,
|
||||
volumeSMAWindow: Int,
|
||||
volumeMultiplier: Double,
|
||||
grayFake: Bool,
|
||||
hideMarketClosedGaps: Bool,
|
||||
enableAutoRefresh: Bool,
|
||||
refreshSeconds: Int
|
||||
) {
|
||||
self.symbol = symbol
|
||||
self.interval = interval
|
||||
self.period = period
|
||||
self.maxBars = maxBars
|
||||
self.dropLive = dropLive
|
||||
self.useBodyRange = useBodyRange
|
||||
self.volumeFilterEnabled = volumeFilterEnabled
|
||||
self.volumeSMAWindow = volumeSMAWindow
|
||||
self.volumeMultiplier = volumeMultiplier
|
||||
self.grayFake = grayFake
|
||||
self.hideMarketClosedGaps = hideMarketClosedGaps
|
||||
self.enableAutoRefresh = enableAutoRefresh
|
||||
self.refreshSeconds = refreshSeconds
|
||||
}
|
||||
|
||||
static let `default` = WebPreferences(
|
||||
symbol: "AAPL",
|
||||
interval: "1d",
|
||||
period: "6mo",
|
||||
maxBars: 500,
|
||||
dropLive: true,
|
||||
useBodyRange: false,
|
||||
volumeFilterEnabled: false,
|
||||
volumeSMAWindow: 20,
|
||||
volumeMultiplier: 1.0,
|
||||
grayFake: true,
|
||||
hideMarketClosedGaps: true,
|
||||
enableAutoRefresh: false,
|
||||
refreshSeconds: 60
|
||||
)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case symbol
|
||||
case interval
|
||||
case period
|
||||
case maxBars = "max_bars"
|
||||
case dropLive = "drop_live"
|
||||
case useBodyRange = "use_body_range"
|
||||
case volumeFilterEnabled = "volume_filter_enabled"
|
||||
case volumeSMAWindow = "volume_sma_window"
|
||||
case volumeMultiplier = "volume_multiplier"
|
||||
case grayFake = "gray_fake"
|
||||
case hideMarketClosedGaps = "hide_market_closed_gaps"
|
||||
case enableAutoRefresh = "enable_auto_refresh"
|
||||
case refreshSeconds = "refresh_sec"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let defaults = Self.default
|
||||
|
||||
symbol = try container.decodeIfPresent(String.self, forKey: .symbol) ?? defaults.symbol
|
||||
interval = try container.decodeIfPresent(String.self, forKey: .interval) ?? defaults.interval
|
||||
period = try container.decodeIfPresent(String.self, forKey: .period) ?? defaults.period
|
||||
maxBars = try container.decodeIfPresent(Int.self, forKey: .maxBars) ?? defaults.maxBars
|
||||
dropLive = try container.decodeIfPresent(Bool.self, forKey: .dropLive) ?? defaults.dropLive
|
||||
useBodyRange = try container.decodeIfPresent(Bool.self, forKey: .useBodyRange) ?? defaults.useBodyRange
|
||||
volumeFilterEnabled = try container.decodeIfPresent(Bool.self, forKey: .volumeFilterEnabled) ?? defaults.volumeFilterEnabled
|
||||
volumeSMAWindow = try container.decodeIfPresent(Int.self, forKey: .volumeSMAWindow) ?? defaults.volumeSMAWindow
|
||||
volumeMultiplier = try container.decodeIfPresent(Double.self, forKey: .volumeMultiplier) ?? defaults.volumeMultiplier
|
||||
grayFake = try container.decodeIfPresent(Bool.self, forKey: .grayFake) ?? defaults.grayFake
|
||||
hideMarketClosedGaps = try container.decodeIfPresent(Bool.self, forKey: .hideMarketClosedGaps) ?? defaults.hideMarketClosedGaps
|
||||
enableAutoRefresh = try container.decodeIfPresent(Bool.self, forKey: .enableAutoRefresh) ?? defaults.enableAutoRefresh
|
||||
refreshSeconds = try container.decodeIfPresent(Int.self, forKey: .refreshSeconds) ?? defaults.refreshSeconds
|
||||
}
|
||||
|
||||
func normalized() -> WebPreferences {
|
||||
let safeSymbol = {
|
||||
let candidate = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
return candidate.isEmpty ? "AAPL" : candidate
|
||||
}()
|
||||
|
||||
let safeInterval = UserSetupPreferences.timeframeOptions.contains(interval) ? interval : "1d"
|
||||
let safePeriod = UserSetupPreferences.periodOptions.contains(period) ? period : "6mo"
|
||||
let safeMaxBars = min(5000, max(20, maxBars))
|
||||
let safeVolumeSMAWindow = min(100, max(2, volumeSMAWindow))
|
||||
let clampedMultiplier = min(3.0, max(0.1, volumeMultiplier))
|
||||
let safeVolumeMultiplier = (clampedMultiplier * 10).rounded() / 10
|
||||
let safeRefreshSeconds = min(600, max(10, refreshSeconds))
|
||||
|
||||
return WebPreferences(
|
||||
symbol: safeSymbol,
|
||||
interval: safeInterval,
|
||||
period: safePeriod,
|
||||
maxBars: safeMaxBars,
|
||||
dropLive: dropLive,
|
||||
useBodyRange: useBodyRange,
|
||||
volumeFilterEnabled: volumeFilterEnabled,
|
||||
volumeSMAWindow: safeVolumeSMAWindow,
|
||||
volumeMultiplier: safeVolumeMultiplier,
|
||||
grayFake: grayFake,
|
||||
hideMarketClosedGaps: hideMarketClosedGaps,
|
||||
enableAutoRefresh: enableAutoRefresh,
|
||||
refreshSeconds: safeRefreshSeconds
|
||||
)
|
||||
}
|
||||
|
||||
var setupDefaults: UserSetupPreferences {
|
||||
UserSetupPreferences(
|
||||
symbol: symbol,
|
||||
timeframe: interval,
|
||||
period: period,
|
||||
maxBars: maxBars
|
||||
).normalized()
|
||||
}
|
||||
|
||||
func applyingSetup(_ setup: UserSetupPreferences) -> WebPreferences {
|
||||
let normalizedSetup = setup.normalized()
|
||||
return WebPreferences(
|
||||
symbol: normalizedSetup.symbol,
|
||||
interval: normalizedSetup.timeframe,
|
||||
period: normalizedSetup.period,
|
||||
maxBars: normalizedSetup.maxBars,
|
||||
dropLive: dropLive,
|
||||
useBodyRange: useBodyRange,
|
||||
volumeFilterEnabled: volumeFilterEnabled,
|
||||
volumeSMAWindow: volumeSMAWindow,
|
||||
volumeMultiplier: volumeMultiplier,
|
||||
grayFake: grayFake,
|
||||
hideMarketClosedGaps: hideMarketClosedGaps,
|
||||
enableAutoRefresh: enableAutoRefresh,
|
||||
refreshSeconds: refreshSeconds
|
||||
)
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
[
|
||||
URLQueryItem(name: "symbol", value: symbol),
|
||||
URLQueryItem(name: "interval", value: interval),
|
||||
URLQueryItem(name: "period", value: period),
|
||||
URLQueryItem(name: "max_bars", value: String(maxBars)),
|
||||
URLQueryItem(name: "drop_live", value: String(dropLive)),
|
||||
URLQueryItem(name: "use_body_range", value: String(useBodyRange)),
|
||||
URLQueryItem(name: "volume_filter_enabled", value: String(volumeFilterEnabled)),
|
||||
URLQueryItem(name: "volume_sma_window", value: String(volumeSMAWindow)),
|
||||
URLQueryItem(name: "volume_multiplier", value: String(volumeMultiplier)),
|
||||
URLQueryItem(name: "gray_fake", value: String(grayFake)),
|
||||
URLQueryItem(name: "hide_market_closed_gaps", value: String(hideMarketClosedGaps)),
|
||||
URLQueryItem(name: "enable_auto_refresh", value: String(enableAutoRefresh)),
|
||||
URLQueryItem(name: "refresh_sec", value: String(refreshSeconds)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private struct LocalWebView: NSViewRepresentable {
|
||||
let url: URL
|
||||
let reloadToken: Int
|
||||
|
||||
func makeNSView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
webView.customUserAgent = "ManeshTraderMac"
|
||||
webView.allowsBackForwardNavigationGestures = false
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.attach(webView)
|
||||
webView.load(URLRequest(url: url))
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateNSView(_ webView: WKWebView, context: Context) {
|
||||
if context.coordinator.lastReloadToken != reloadToken || webView.url != url {
|
||||
context.coordinator.lastReloadToken = reloadToken
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(url: url, reloadToken: reloadToken)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
private let url: URL
|
||||
private weak var webView: WKWebView?
|
||||
private var pendingRetry: DispatchWorkItem?
|
||||
var lastReloadToken: Int
|
||||
|
||||
init(url: URL, reloadToken: Int) {
|
||||
self.url = url
|
||||
self.lastReloadToken = reloadToken
|
||||
}
|
||||
|
||||
func attach(_ webView: WKWebView) {
|
||||
self.webView = webView
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
pendingRetry?.cancel()
|
||||
pendingRetry = nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
pendingRetry?.cancel()
|
||||
let retry = DispatchWorkItem { [weak self] in
|
||||
guard let self, let webView = self.webView else { return }
|
||||
webView.load(URLRequest(url: self.url))
|
||||
}
|
||||
pendingRetry = retry
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
private final class TraderHost {
|
||||
var isRunning = false
|
||||
var isStarting = false
|
||||
var launchError: String?
|
||||
var status = "Preparing backend..."
|
||||
var backendExecutablePath = ""
|
||||
var reloadToken = 0
|
||||
var serverPort = 8501
|
||||
var webQueryItems: [URLQueryItem] = []
|
||||
var serverURL: URL {
|
||||
var components = URLComponents()
|
||||
components.scheme = "http"
|
||||
components.host = "127.0.0.1"
|
||||
components.port = serverPort
|
||||
components.queryItems = webQueryItems.isEmpty ? nil : webQueryItems
|
||||
return components.url!
|
||||
}
|
||||
|
||||
private var process: Process?
|
||||
private var outputPipe: Pipe?
|
||||
private var latestBackendLogLine = ""
|
||||
private var lastSignificantBackendLogLine = ""
|
||||
private var didRequestStop = false
|
||||
|
||||
func start() {
|
||||
guard process == nil else { return }
|
||||
|
||||
isStarting = true
|
||||
launchError = nil
|
||||
status = "Starting local engine..."
|
||||
didRequestStop = false
|
||||
|
||||
guard let executableURL = bundledBackendExecutableURL() else {
|
||||
isStarting = false
|
||||
launchError = "Required backend files were not found in the app bundle."
|
||||
status = "Bundled backend executable not found."
|
||||
return
|
||||
}
|
||||
backendExecutablePath = executableURL.path
|
||||
|
||||
do {
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executableURL.path)
|
||||
} catch {
|
||||
status = "Could not set executable permissions: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
let p = Process()
|
||||
latestBackendLogLine = ""
|
||||
lastSignificantBackendLogLine = ""
|
||||
serverPort = nextAvailablePort(preferred: 8501)
|
||||
p.currentDirectoryURL = executableURL.deletingLastPathComponent()
|
||||
p.executableURL = executableURL
|
||||
var environment = ProcessInfo.processInfo.environment
|
||||
environment["MANESH_TRADER_PORT"] = "\(serverPort)"
|
||||
p.environment = environment
|
||||
|
||||
let pipe = Pipe()
|
||||
outputPipe = pipe
|
||||
p.standardOutput = pipe
|
||||
p.standardError = pipe
|
||||
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return }
|
||||
let lastLine = text
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.last
|
||||
.map(String.init)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !lastLine.isEmpty else { return }
|
||||
DispatchQueue.main.async {
|
||||
self?.latestBackendLogLine = lastLine
|
||||
if !lastLine.contains("LOADER: failed to destroy sync semaphore") {
|
||||
self?.lastSignificantBackendLogLine = lastLine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.terminationHandler = { [weak self] proc in
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.isRunning = false
|
||||
self.isStarting = false
|
||||
self.process = nil
|
||||
self.outputPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
self.outputPipe = nil
|
||||
|
||||
if self.didRequestStop {
|
||||
self.status = "Stopped."
|
||||
return
|
||||
}
|
||||
|
||||
if let line = self.lastSignificantBackendLogLine.isEmpty ? nil : self.lastSignificantBackendLogLine {
|
||||
self.status = "Stopped (exit \(proc.terminationStatus)): \(line)"
|
||||
} else if let line = self.latestBackendLogLine.isEmpty ? nil : self.latestBackendLogLine {
|
||||
self.status = "Stopped (exit \(proc.terminationStatus)): \(line)"
|
||||
} else {
|
||||
self.status = "Stopped (exit \(proc.terminationStatus))."
|
||||
}
|
||||
|
||||
if proc.terminationStatus != 0 {
|
||||
self.launchError = "The local engine stopped unexpectedly. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try p.run()
|
||||
process = p
|
||||
isRunning = true
|
||||
isStarting = false
|
||||
reloadToken += 1
|
||||
status = "Running locally in-app at \(serverURL.absoluteString)"
|
||||
} catch {
|
||||
status = "Failed to start backend: \(error.localizedDescription)"
|
||||
process = nil
|
||||
outputPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
outputPipe = nil
|
||||
isRunning = false
|
||||
isStarting = false
|
||||
launchError = "Unable to start the local engine. Please try again."
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
guard let process else { return }
|
||||
didRequestStop = true
|
||||
isStarting = false
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
}
|
||||
outputPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
outputPipe = nil
|
||||
self.process = nil
|
||||
isRunning = false
|
||||
status = "Stopped."
|
||||
}
|
||||
|
||||
func reloadWebView() {
|
||||
guard isRunning else { return }
|
||||
reloadToken += 1
|
||||
}
|
||||
|
||||
func applyPreferences(_ preferences: WebPreferences) {
|
||||
webQueryItems = preferences.normalized().queryItems
|
||||
}
|
||||
|
||||
func restart() {
|
||||
stop()
|
||||
start()
|
||||
}
|
||||
|
||||
private func bundledBackendExecutableURL() -> URL? {
|
||||
let fm = FileManager.default
|
||||
let candidates = [
|
||||
Bundle.main.url(forResource: "WebBackend", withExtension: nil, subdirectory: "EmbeddedBackend"),
|
||||
Bundle.main.resourceURL?.appendingPathComponent("EmbeddedBackend/WebBackend"),
|
||||
Bundle.main.url(forResource: "WebBackend", withExtension: nil),
|
||||
Bundle.main.url(forResource: "ManeshTraderBackend", withExtension: nil, subdirectory: "EmbeddedBackend"),
|
||||
Bundle.main.resourceURL?.appendingPathComponent("EmbeddedBackend/ManeshTraderBackend"),
|
||||
Bundle.main.url(forResource: "ManeshTraderBackend", withExtension: nil),
|
||||
].compactMap { $0 }
|
||||
|
||||
return candidates.first(where: { fm.fileExists(atPath: $0.path) })
|
||||
}
|
||||
|
||||
private func nextAvailablePort(preferred: Int) -> Int {
|
||||
if canBindToLocalPort(preferred) {
|
||||
return preferred
|
||||
}
|
||||
|
||||
for candidate in 8502...8600 where canBindToLocalPort(candidate) {
|
||||
return candidate
|
||||
}
|
||||
return preferred
|
||||
}
|
||||
|
||||
private func canBindToLocalPort(_ port: Int) -> Bool {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
guard fd >= 0 else { return false }
|
||||
defer { close(fd) }
|
||||
|
||||
var value: Int32 = 1
|
||||
_ = setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &value, socklen_t(MemoryLayout<Int32>.size))
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.stride)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = in_port_t(UInt16(port).bigEndian)
|
||||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
|
||||
return withUnsafePointer(to: &addr) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockPtr in
|
||||
bind(fd, sockPtr, socklen_t(MemoryLayout<sockaddr_in>.stride)) == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
mac/src/App/EmbeddedBackend/README.md
Normal file
@ -0,0 +1,6 @@
|
||||
This folder is populated by `scripts/build_embedded_backend.sh`.
|
||||
|
||||
- Output binary: `WebBackend`
|
||||
- Source of truth for backend code: `web/src/` (`app.py`, `web_core/`)
|
||||
|
||||
Do not hand-edit generated binaries in this folder.
|
||||
BIN
mac/src/App/EmbeddedBackend/WebBackend
Executable file
196
mac/src/App/Help/help.html
Normal file
@ -0,0 +1,196 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Help & Quick Start</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0e1117;
|
||||
--panel: #131925;
|
||||
--text: #f3f6fb;
|
||||
--muted: #a6b3c7;
|
||||
--accent: #4db2ff;
|
||||
--ok: #4bd37b;
|
||||
--warn: #ff9f43;
|
||||
--line: #2a3346;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f8fc;
|
||||
--panel: #ffffff;
|
||||
--text: #172033;
|
||||
--muted: #5b6980;
|
||||
--accent: #006fde;
|
||||
--ok: #12834a;
|
||||
--warn: #b85b00;
|
||||
--line: #d7deea;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at top right, rgba(77, 178, 255, 0.18), transparent 40%), var(--bg);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 56px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
section {
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--panel) 92%, transparent);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: color-mix(in oklab, var(--panel) 80%, var(--line));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
margin-right: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Help & Quick Start</h1>
|
||||
<p class="subtitle">A quick guide to reading signals, choosing settings, and troubleshooting.</p>
|
||||
|
||||
<section>
|
||||
<h2>Start in 60 Seconds</h2>
|
||||
<ol>
|
||||
<li>Set a symbol like <code>AAPL</code> or <code>BTC-USD</code>.</li>
|
||||
<li>Choose <code>Timeframe</code> (<code>1d</code> is a good default) and <code>Period</code> (<code>6mo</code>).</li>
|
||||
<li>Keep <code>Ignore potentially live last bar</code> enabled.</li>
|
||||
<li>Review trend status and chart markers.</li>
|
||||
<li>Use Export to download CSV/PDF outputs.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Signal Rules</h2>
|
||||
<p>
|
||||
<span class="tag"><span class="ok">real_bull</span> close above previous high</span>
|
||||
<span class="tag"><span class="warn">real_bear</span> close below previous low</span>
|
||||
<span class="tag">fake close inside previous range</span>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Trend starts after <strong>2 consecutive real bars</strong> in the same direction.</li>
|
||||
<li>Trend reverses only after <strong>2 consecutive opposite real bars</strong>.</li>
|
||||
<li>Fake bars are noise and do not reverse trend.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Data Settings</h2>
|
||||
<h3>Core fields</h3>
|
||||
<ul>
|
||||
<li><code>Symbol</code>: ticker or pair, e.g. <code>AAPL</code>, <code>MSFT</code>, <code>BTC-USD</code>.</li>
|
||||
<li><code>Timeframe</code>: candle size. Start with <code>1d</code> for cleaner swings.</li>
|
||||
<li><code>Period</code>: amount of history to load. Start with <code>6mo</code>.</li>
|
||||
<li><code>Max bars</code>: limits loaded candles for speed and chart readability.</li>
|
||||
</ul>
|
||||
<h3>Optional filters</h3>
|
||||
<ul>
|
||||
<li><code>Use previous body range</code>: ignores wick-only breakouts.</li>
|
||||
<li><code>Enable volume filter</code>: treats low-volume bars as fake.</li>
|
||||
<li><code>Hide market-closed gaps</code>: recommended ON for stocks.</li>
|
||||
<li><code>Enable auto-refresh</code>: useful for live monitoring only.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Chart Reading</h2>
|
||||
<ul>
|
||||
<li>Green triangle-up markers show <code>real_bull</code> bars.</li>
|
||||
<li>Red triangle-down markers show <code>real_bear</code> bars.</li>
|
||||
<li>Gray candles (if enabled) de-emphasize fake/noise bars.</li>
|
||||
<li>Volume bars are color-coded by trend state.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Troubleshooting</h2>
|
||||
<ul>
|
||||
<li>If no data appears, verify ticker format (for example <code>BTC-USD</code>, not <code>BTCUSD</code>).</li>
|
||||
<li>If results look noisy, switch to <code>1d</code> and reduce optional filters.</li>
|
||||
<li>If trend seems delayed, remember trend transitions require two real bars.</li>
|
||||
<li>This tool is analysis-only and does not place trades.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
18
mac/src/App/ManeshTraderMacApp.swift
Normal file
@ -0,0 +1,18 @@
|
||||
//
|
||||
// ManeshTraderMacApp.swift
|
||||
// ManeshTraderMac
|
||||
//
|
||||
// Created by Matt Bruce on 2/13/26.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ManeshTraderMacApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.navigationTitle("ManeshTrader")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,110 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon-20@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-20@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-29@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-29@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-60@2x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "2x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-60@3x.png",
|
||||
"idiom" : "iphone",
|
||||
"scale" : "3x",
|
||||
"size" : "60x60"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-20@1x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-20@2x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "20x20"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-29@1x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-29@2x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "29x29"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40@1x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "1x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-40@2x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "40x40"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-76@2x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "76x76"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-83.5@2x-ipad.png",
|
||||
"idiom" : "ipad",
|
||||
"scale" : "2x",
|
||||
"size" : "83.5x83.5"
|
||||
},
|
||||
{
|
||||
"filename" : "icon-1024-marketing.png",
|
||||
"idiom" : "ios-marketing",
|
||||
"scale" : "1x",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 14 KiB |
6
mac/src/AppMobile/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
196
mac/src/AppMobile/Help/help.html
Normal file
@ -0,0 +1,196 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Help & Quick Start</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0e1117;
|
||||
--panel: #131925;
|
||||
--text: #f3f6fb;
|
||||
--muted: #a6b3c7;
|
||||
--accent: #4db2ff;
|
||||
--ok: #4bd37b;
|
||||
--warn: #ff9f43;
|
||||
--line: #2a3346;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: #f6f8fc;
|
||||
--panel: #ffffff;
|
||||
--text: #172033;
|
||||
--muted: #5b6980;
|
||||
--accent: #006fde;
|
||||
--ok: #12834a;
|
||||
--warn: #b85b00;
|
||||
--line: #d7deea;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: var(--text);
|
||||
background: radial-gradient(circle at top right, rgba(77, 178, 255, 0.18), transparent 40%), var(--bg);
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 56px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0 0 20px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
section {
|
||||
border: 1px solid var(--line);
|
||||
background: color-mix(in oklab, var(--panel) 92%, transparent);
|
||||
border-radius: 14px;
|
||||
padding: 16px 18px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 12px 0 8px;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
p,
|
||||
li {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
code {
|
||||
background: color-mix(in oklab, var(--panel) 80%, var(--line));
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 6px;
|
||||
padding: 1px 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
margin-right: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: var(--ok);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warn {
|
||||
color: var(--warn);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>Help & Quick Start</h1>
|
||||
<p class="subtitle">A quick guide to reading signals, choosing settings, and troubleshooting.</p>
|
||||
|
||||
<section>
|
||||
<h2>Start in 60 Seconds</h2>
|
||||
<ol>
|
||||
<li>Set a symbol like <code>AAPL</code> or <code>BTC-USD</code>.</li>
|
||||
<li>Choose <code>Timeframe</code> (<code>1d</code> is a good default) and <code>Period</code> (<code>6mo</code>).</li>
|
||||
<li>Keep <code>Ignore potentially live last bar</code> enabled.</li>
|
||||
<li>Review trend status and chart markers.</li>
|
||||
<li>Use Export to download CSV/PDF outputs.</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Signal Rules</h2>
|
||||
<p>
|
||||
<span class="tag"><span class="ok">real_bull</span> close above previous high</span>
|
||||
<span class="tag"><span class="warn">real_bear</span> close below previous low</span>
|
||||
<span class="tag">fake close inside previous range</span>
|
||||
</p>
|
||||
<ul>
|
||||
<li>Trend starts after <strong>2 consecutive real bars</strong> in the same direction.</li>
|
||||
<li>Trend reverses only after <strong>2 consecutive opposite real bars</strong>.</li>
|
||||
<li>Fake bars are noise and do not reverse trend.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Data Settings</h2>
|
||||
<h3>Core fields</h3>
|
||||
<ul>
|
||||
<li><code>Symbol</code>: ticker or pair, e.g. <code>AAPL</code>, <code>MSFT</code>, <code>BTC-USD</code>.</li>
|
||||
<li><code>Timeframe</code>: candle size. Start with <code>1d</code> for cleaner swings.</li>
|
||||
<li><code>Period</code>: amount of history to load. Start with <code>6mo</code>.</li>
|
||||
<li><code>Max bars</code>: limits loaded candles for speed and chart readability.</li>
|
||||
</ul>
|
||||
<h3>Optional filters</h3>
|
||||
<ul>
|
||||
<li><code>Use previous body range</code>: ignores wick-only breakouts.</li>
|
||||
<li><code>Enable volume filter</code>: treats low-volume bars as fake.</li>
|
||||
<li><code>Hide market-closed gaps</code>: recommended ON for stocks.</li>
|
||||
<li><code>Enable auto-refresh</code>: useful for live monitoring only.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Chart Reading</h2>
|
||||
<ul>
|
||||
<li>Green triangle-up markers show <code>real_bull</code> bars.</li>
|
||||
<li>Red triangle-down markers show <code>real_bear</code> bars.</li>
|
||||
<li>Gray candles (if enabled) de-emphasize fake/noise bars.</li>
|
||||
<li>Volume bars are color-coded by trend state.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Troubleshooting</h2>
|
||||
<ul>
|
||||
<li>If no data appears, verify ticker format (for example <code>BTC-USD</code>, not <code>BTCUSD</code>).</li>
|
||||
<li>If results look noisy, switch to <code>1d</code> and reduce optional filters.</li>
|
||||
<li>If trend seems delayed, remember trend transitions require two real bars.</li>
|
||||
<li>This tool is analysis-only and does not place trades.</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
10
mac/src/AppMobile/ManeshTraderMobileApp.swift
Normal file
@ -0,0 +1,10 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct ManeshTraderMobileApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
MobileContentView()
|
||||
}
|
||||
}
|
||||
}
|
||||
615
mac/src/AppMobile/MobileContentView.swift
Normal file
@ -0,0 +1,615 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
import Foundation
|
||||
import Observation
|
||||
|
||||
struct MobileContentView: View {
|
||||
@State private var host = MobileTraderHost()
|
||||
@State private var didAutostart = false
|
||||
@AppStorage("mt_mobile_setup_completed") private var setupCompleted = false
|
||||
@AppStorage("mt_mobile_backend_url") private var storedBackendURL = "http://127.0.0.1:8501"
|
||||
@AppStorage("mt_mobile_symbol") private var storedSymbol = "AAPL"
|
||||
@AppStorage("mt_mobile_interval") private var storedInterval = "1d"
|
||||
@AppStorage("mt_mobile_period") private var storedPeriod = "6mo"
|
||||
@AppStorage("mt_mobile_max_bars") private var storedMaxBars = 500
|
||||
@AppStorage("mt_mobile_drop_live") private var storedDropLive = true
|
||||
@AppStorage("mt_mobile_use_body_range") private var storedUseBodyRange = false
|
||||
@AppStorage("mt_mobile_volume_filter_enabled") private var storedVolumeFilterEnabled = false
|
||||
@AppStorage("mt_mobile_volume_sma_window") private var storedVolumeSMAWindow = 20
|
||||
@AppStorage("mt_mobile_volume_multiplier") private var storedVolumeMultiplier = 1.0
|
||||
@AppStorage("mt_mobile_gray_fake") private var storedGrayFake = true
|
||||
@AppStorage("mt_mobile_hide_market_closed_gaps") private var storedHideMarketClosedGaps = true
|
||||
@AppStorage("mt_mobile_enable_auto_refresh") private var storedEnableAutoRefresh = false
|
||||
@AppStorage("mt_mobile_refresh_sec") private var storedRefreshSeconds = 60
|
||||
|
||||
@State private var showSetupSheet = false
|
||||
@State private var showHelpSheet = false
|
||||
@State private var setupDraft = MobileUserSetupPreferences.default
|
||||
@State private var setupBackendURL = "http://127.0.0.1:8501"
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let url = host.serverURL {
|
||||
MobileWebView(url: url, reloadToken: host.reloadToken)
|
||||
} else {
|
||||
launchView
|
||||
}
|
||||
}
|
||||
.navigationTitle("ManeshTrader")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button("Help") {
|
||||
showHelpSheet = true
|
||||
}
|
||||
|
||||
Button("Setup") {
|
||||
setupDraft = storedWebPreferences.normalized().setupDefaults
|
||||
setupBackendURL = storedBackendURL
|
||||
showSetupSheet = true
|
||||
}
|
||||
|
||||
Button("Reload") {
|
||||
_ = syncHostPreferencesFromStorage()
|
||||
host.reloadWebView()
|
||||
}
|
||||
.disabled(host.serverURL == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
guard !didAutostart else { return }
|
||||
didAutostart = true
|
||||
|
||||
let normalized = syncHostPreferencesFromStorage()
|
||||
if !setupCompleted {
|
||||
setupDraft = normalized.setupDefaults
|
||||
setupBackendURL = storedBackendURL
|
||||
showSetupSheet = true
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showSetupSheet) {
|
||||
setupSheet
|
||||
}
|
||||
.sheet(isPresented: $showHelpSheet) {
|
||||
MobileHelpSheetView()
|
||||
}
|
||||
}
|
||||
|
||||
private var storedWebPreferences: MobileWebPreferences {
|
||||
MobileWebPreferences(
|
||||
symbol: storedSymbol,
|
||||
interval: storedInterval,
|
||||
period: storedPeriod,
|
||||
maxBars: storedMaxBars,
|
||||
dropLive: storedDropLive,
|
||||
useBodyRange: storedUseBodyRange,
|
||||
volumeFilterEnabled: storedVolumeFilterEnabled,
|
||||
volumeSMAWindow: storedVolumeSMAWindow,
|
||||
volumeMultiplier: storedVolumeMultiplier,
|
||||
grayFake: storedGrayFake,
|
||||
hideMarketClosedGaps: storedHideMarketClosedGaps,
|
||||
enableAutoRefresh: storedEnableAutoRefresh,
|
||||
refreshSeconds: storedRefreshSeconds
|
||||
)
|
||||
}
|
||||
|
||||
private var setupSheet: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section {
|
||||
Text("Connect this app to a reachable ManeshTrader web backend. iOS cannot run the embedded backend executable directly.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Backend") {
|
||||
TextField("Base URL", text: $setupBackendURL)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.keyboardType(.URL)
|
||||
Text("Examples: http://192.168.1.10:8501 or https://your-host")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Section("Data Defaults") {
|
||||
TextField("Symbol", text: $setupDraft.symbol)
|
||||
.textInputAutocapitalization(.characters)
|
||||
.autocorrectionDisabled(true)
|
||||
|
||||
Picker("Timeframe", selection: $setupDraft.timeframe) {
|
||||
ForEach(MobileUserSetupPreferences.timeframeOptions, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
|
||||
Picker("Period", selection: $setupDraft.period) {
|
||||
ForEach(MobileUserSetupPreferences.periodOptions, id: \.self) { option in
|
||||
Text(option).tag(option)
|
||||
}
|
||||
}
|
||||
|
||||
Stepper(value: $setupDraft.maxBars, in: 20...5000, step: 10) {
|
||||
Text("Max bars: \(setupDraft.maxBars)")
|
||||
}
|
||||
}
|
||||
|
||||
if let validationMessage = host.validationMessage {
|
||||
Section {
|
||||
Text(validationMessage)
|
||||
.foregroundStyle(.red)
|
||||
.font(.footnote)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Setup")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Use Defaults") {
|
||||
applySetup(MobileUserSetupPreferences.default, backendURL: setupBackendURL, markCompleted: true)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
applySetup(setupDraft.normalized(), backendURL: setupBackendURL, markCompleted: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applySetup(_ preferences: MobileUserSetupPreferences, backendURL: String, markCompleted: Bool) {
|
||||
let normalizedSetup = preferences.normalized()
|
||||
let mergedPreferences = storedWebPreferences
|
||||
.applyingSetup(normalizedSetup)
|
||||
.normalized()
|
||||
|
||||
storedBackendURL = backendURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
writeWebPreferencesToStorage(mergedPreferences)
|
||||
|
||||
host.applyPreferences(mergedPreferences, backendURLString: storedBackendURL)
|
||||
host.reloadWebView()
|
||||
|
||||
if markCompleted {
|
||||
setupCompleted = true
|
||||
}
|
||||
|
||||
if host.serverURL != nil {
|
||||
showSetupSheet = false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func syncHostPreferencesFromStorage() -> MobileWebPreferences {
|
||||
let normalized = storedWebPreferences.normalized()
|
||||
writeWebPreferencesToStorage(normalized)
|
||||
host.applyPreferences(normalized, backendURLString: storedBackendURL)
|
||||
return normalized
|
||||
}
|
||||
|
||||
private func writeWebPreferencesToStorage(_ preferences: MobileWebPreferences) {
|
||||
storedSymbol = preferences.symbol
|
||||
storedInterval = preferences.interval
|
||||
storedPeriod = preferences.period
|
||||
storedMaxBars = preferences.maxBars
|
||||
storedDropLive = preferences.dropLive
|
||||
storedUseBodyRange = preferences.useBodyRange
|
||||
storedVolumeFilterEnabled = preferences.volumeFilterEnabled
|
||||
storedVolumeSMAWindow = preferences.volumeSMAWindow
|
||||
storedVolumeMultiplier = preferences.volumeMultiplier
|
||||
storedGrayFake = preferences.grayFake
|
||||
storedHideMarketClosedGaps = preferences.hideMarketClosedGaps
|
||||
storedEnableAutoRefresh = preferences.enableAutoRefresh
|
||||
storedRefreshSeconds = preferences.refreshSeconds
|
||||
}
|
||||
|
||||
private var launchView: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "network.badge.shield.half.filled")
|
||||
.font(.system(size: 42))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Connect ManeshTrader")
|
||||
.font(.title3.weight(.semibold))
|
||||
|
||||
Text("Set a backend URL reachable from this device, then load the web app in place.")
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 460)
|
||||
|
||||
Button("Open Setup") {
|
||||
setupDraft = storedWebPreferences.normalized().setupDefaults
|
||||
setupBackendURL = storedBackendURL
|
||||
showSetupSheet = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
if let validationMessage = host.validationMessage {
|
||||
Text(validationMessage)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.red)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 460)
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(uiColor: .systemBackground),
|
||||
Color(uiColor: .secondarySystemBackground),
|
||||
],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileHelpSheetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private var helpFileURL: URL? {
|
||||
let fm = FileManager.default
|
||||
let candidates = [
|
||||
Bundle.main.url(forResource: "help", withExtension: "html", subdirectory: "Help"),
|
||||
Bundle.main.url(forResource: "help", withExtension: "html"),
|
||||
Bundle.main.resourceURL?.appendingPathComponent("Help/help.html"),
|
||||
Bundle.main.resourceURL?.appendingPathComponent("help.html"),
|
||||
].compactMap { $0 }
|
||||
|
||||
return candidates.first(where: { fm.fileExists(atPath: $0.path) })
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if let helpFileURL {
|
||||
MobileHelpDocumentWebView(fileURL: helpFileURL)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Help file not found.")
|
||||
.font(.title3.weight(.semibold))
|
||||
Text("Expected bundled file: help.html")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.padding(24)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Help & Quick Start")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Done") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileHelpDocumentWebView: UIViewRepresentable {
|
||||
let fileURL: URL
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if webView.url != fileURL {
|
||||
webView.loadFileURL(fileURL, allowingReadAccessTo: fileURL.deletingLastPathComponent())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileWebView: UIViewRepresentable {
|
||||
let url: URL
|
||||
let reloadToken: Int
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView(frame: .zero)
|
||||
webView.customUserAgent = "ManeshTraderMobile"
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.attach(webView)
|
||||
webView.load(URLRequest(url: url))
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
if context.coordinator.lastReloadToken != reloadToken || webView.url != url {
|
||||
context.coordinator.lastReloadToken = reloadToken
|
||||
webView.load(URLRequest(url: url))
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(url: url, reloadToken: reloadToken)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
private let url: URL
|
||||
private weak var webView: WKWebView?
|
||||
private var pendingRetry: DispatchWorkItem?
|
||||
var lastReloadToken: Int
|
||||
|
||||
init(url: URL, reloadToken: Int) {
|
||||
self.url = url
|
||||
self.lastReloadToken = reloadToken
|
||||
}
|
||||
|
||||
func attach(_ webView: WKWebView) {
|
||||
self.webView = webView
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
pendingRetry?.cancel()
|
||||
pendingRetry = nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||
scheduleRetry()
|
||||
}
|
||||
|
||||
private func scheduleRetry() {
|
||||
pendingRetry?.cancel()
|
||||
let retry = DispatchWorkItem { [weak self] in
|
||||
guard let self, let webView = self.webView else { return }
|
||||
webView.load(URLRequest(url: self.url))
|
||||
}
|
||||
pendingRetry = retry
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0, execute: retry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
private final class MobileTraderHost {
|
||||
var reloadToken = 0
|
||||
var validationMessage: String?
|
||||
var webQueryItems: [URLQueryItem] = []
|
||||
var backendURLString = ""
|
||||
|
||||
var serverURL: URL? {
|
||||
guard var components = URLComponents(string: backendURLString),
|
||||
let scheme = components.scheme?.lowercased(),
|
||||
["http", "https"].contains(scheme),
|
||||
components.host != nil else {
|
||||
return nil
|
||||
}
|
||||
|
||||
components.queryItems = webQueryItems.isEmpty ? nil : webQueryItems
|
||||
return components.url
|
||||
}
|
||||
|
||||
func applyPreferences(_ preferences: MobileWebPreferences, backendURLString: String) {
|
||||
self.backendURLString = backendURLString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
webQueryItems = preferences.normalized().queryItems
|
||||
|
||||
if serverURL == nil {
|
||||
validationMessage = "Enter a valid backend URL with http:// or https:// and a host."
|
||||
} else {
|
||||
validationMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
func reloadWebView() {
|
||||
guard serverURL != nil else { return }
|
||||
reloadToken += 1
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileUserSetupPreferences {
|
||||
static let timeframeOptions = ["1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h", "1d", "5d", "1wk", "1mo"]
|
||||
static let periodOptions = ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "max"]
|
||||
|
||||
static let `default` = MobileUserSetupPreferences(symbol: "AAPL", timeframe: "1d", period: "6mo", maxBars: 500)
|
||||
|
||||
var symbol: String
|
||||
var timeframe: String
|
||||
var period: String
|
||||
var maxBars: Int
|
||||
|
||||
func normalized() -> MobileUserSetupPreferences {
|
||||
let normalizedSymbol = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let safeSymbol = normalizedSymbol.isEmpty ? "AAPL" : normalizedSymbol
|
||||
|
||||
let safeTimeframe = Self.timeframeOptions.contains(timeframe) ? timeframe : "1d"
|
||||
let safePeriod = Self.periodOptions.contains(period) ? period : "6mo"
|
||||
let safeMaxBars = min(5000, max(20, maxBars))
|
||||
|
||||
return MobileUserSetupPreferences(
|
||||
symbol: safeSymbol,
|
||||
timeframe: safeTimeframe,
|
||||
period: safePeriod,
|
||||
maxBars: safeMaxBars
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MobileWebPreferences: Codable {
|
||||
var symbol: String
|
||||
var interval: String
|
||||
var period: String
|
||||
var maxBars: Int
|
||||
var dropLive: Bool
|
||||
var useBodyRange: Bool
|
||||
var volumeFilterEnabled: Bool
|
||||
var volumeSMAWindow: Int
|
||||
var volumeMultiplier: Double
|
||||
var grayFake: Bool
|
||||
var hideMarketClosedGaps: Bool
|
||||
var enableAutoRefresh: Bool
|
||||
var refreshSeconds: Int
|
||||
|
||||
init(
|
||||
symbol: String,
|
||||
interval: String,
|
||||
period: String,
|
||||
maxBars: Int,
|
||||
dropLive: Bool,
|
||||
useBodyRange: Bool,
|
||||
volumeFilterEnabled: Bool,
|
||||
volumeSMAWindow: Int,
|
||||
volumeMultiplier: Double,
|
||||
grayFake: Bool,
|
||||
hideMarketClosedGaps: Bool,
|
||||
enableAutoRefresh: Bool,
|
||||
refreshSeconds: Int
|
||||
) {
|
||||
self.symbol = symbol
|
||||
self.interval = interval
|
||||
self.period = period
|
||||
self.maxBars = maxBars
|
||||
self.dropLive = dropLive
|
||||
self.useBodyRange = useBodyRange
|
||||
self.volumeFilterEnabled = volumeFilterEnabled
|
||||
self.volumeSMAWindow = volumeSMAWindow
|
||||
self.volumeMultiplier = volumeMultiplier
|
||||
self.grayFake = grayFake
|
||||
self.hideMarketClosedGaps = hideMarketClosedGaps
|
||||
self.enableAutoRefresh = enableAutoRefresh
|
||||
self.refreshSeconds = refreshSeconds
|
||||
}
|
||||
|
||||
static let `default` = MobileWebPreferences(
|
||||
symbol: "AAPL",
|
||||
interval: "1d",
|
||||
period: "6mo",
|
||||
maxBars: 500,
|
||||
dropLive: true,
|
||||
useBodyRange: false,
|
||||
volumeFilterEnabled: false,
|
||||
volumeSMAWindow: 20,
|
||||
volumeMultiplier: 1.0,
|
||||
grayFake: true,
|
||||
hideMarketClosedGaps: true,
|
||||
enableAutoRefresh: false,
|
||||
refreshSeconds: 60
|
||||
)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case symbol
|
||||
case interval
|
||||
case period
|
||||
case maxBars = "max_bars"
|
||||
case dropLive = "drop_live"
|
||||
case useBodyRange = "use_body_range"
|
||||
case volumeFilterEnabled = "volume_filter_enabled"
|
||||
case volumeSMAWindow = "volume_sma_window"
|
||||
case volumeMultiplier = "volume_multiplier"
|
||||
case grayFake = "gray_fake"
|
||||
case hideMarketClosedGaps = "hide_market_closed_gaps"
|
||||
case enableAutoRefresh = "enable_auto_refresh"
|
||||
case refreshSeconds = "refresh_sec"
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let defaults = Self.default
|
||||
|
||||
symbol = try container.decodeIfPresent(String.self, forKey: .symbol) ?? defaults.symbol
|
||||
interval = try container.decodeIfPresent(String.self, forKey: .interval) ?? defaults.interval
|
||||
period = try container.decodeIfPresent(String.self, forKey: .period) ?? defaults.period
|
||||
maxBars = try container.decodeIfPresent(Int.self, forKey: .maxBars) ?? defaults.maxBars
|
||||
dropLive = try container.decodeIfPresent(Bool.self, forKey: .dropLive) ?? defaults.dropLive
|
||||
useBodyRange = try container.decodeIfPresent(Bool.self, forKey: .useBodyRange) ?? defaults.useBodyRange
|
||||
volumeFilterEnabled = try container.decodeIfPresent(Bool.self, forKey: .volumeFilterEnabled) ?? defaults.volumeFilterEnabled
|
||||
volumeSMAWindow = try container.decodeIfPresent(Int.self, forKey: .volumeSMAWindow) ?? defaults.volumeSMAWindow
|
||||
volumeMultiplier = try container.decodeIfPresent(Double.self, forKey: .volumeMultiplier) ?? defaults.volumeMultiplier
|
||||
grayFake = try container.decodeIfPresent(Bool.self, forKey: .grayFake) ?? defaults.grayFake
|
||||
hideMarketClosedGaps = try container.decodeIfPresent(Bool.self, forKey: .hideMarketClosedGaps) ?? defaults.hideMarketClosedGaps
|
||||
enableAutoRefresh = try container.decodeIfPresent(Bool.self, forKey: .enableAutoRefresh) ?? defaults.enableAutoRefresh
|
||||
refreshSeconds = try container.decodeIfPresent(Int.self, forKey: .refreshSeconds) ?? defaults.refreshSeconds
|
||||
}
|
||||
|
||||
func normalized() -> MobileWebPreferences {
|
||||
let safeSymbol = {
|
||||
let candidate = symbol.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
return candidate.isEmpty ? "AAPL" : candidate
|
||||
}()
|
||||
|
||||
let safeInterval = MobileUserSetupPreferences.timeframeOptions.contains(interval) ? interval : "1d"
|
||||
let safePeriod = MobileUserSetupPreferences.periodOptions.contains(period) ? period : "6mo"
|
||||
let safeMaxBars = min(5000, max(20, maxBars))
|
||||
let safeVolumeSMAWindow = min(100, max(2, volumeSMAWindow))
|
||||
let clampedMultiplier = min(3.0, max(0.1, volumeMultiplier))
|
||||
let safeVolumeMultiplier = (clampedMultiplier * 10).rounded() / 10
|
||||
let safeRefreshSeconds = min(600, max(10, refreshSeconds))
|
||||
|
||||
return MobileWebPreferences(
|
||||
symbol: safeSymbol,
|
||||
interval: safeInterval,
|
||||
period: safePeriod,
|
||||
maxBars: safeMaxBars,
|
||||
dropLive: dropLive,
|
||||
useBodyRange: useBodyRange,
|
||||
volumeFilterEnabled: volumeFilterEnabled,
|
||||
volumeSMAWindow: safeVolumeSMAWindow,
|
||||
volumeMultiplier: safeVolumeMultiplier,
|
||||
grayFake: grayFake,
|
||||
hideMarketClosedGaps: hideMarketClosedGaps,
|
||||
enableAutoRefresh: enableAutoRefresh,
|
||||
refreshSeconds: safeRefreshSeconds
|
||||
)
|
||||
}
|
||||
|
||||
var setupDefaults: MobileUserSetupPreferences {
|
||||
MobileUserSetupPreferences(
|
||||
symbol: symbol,
|
||||
timeframe: interval,
|
||||
period: period,
|
||||
maxBars: maxBars
|
||||
).normalized()
|
||||
}
|
||||
|
||||
func applyingSetup(_ setup: MobileUserSetupPreferences) -> MobileWebPreferences {
|
||||
let normalizedSetup = setup.normalized()
|
||||
return MobileWebPreferences(
|
||||
symbol: normalizedSetup.symbol,
|
||||
interval: normalizedSetup.timeframe,
|
||||
period: normalizedSetup.period,
|
||||
maxBars: normalizedSetup.maxBars,
|
||||
dropLive: dropLive,
|
||||
useBodyRange: useBodyRange,
|
||||
volumeFilterEnabled: volumeFilterEnabled,
|
||||
volumeSMAWindow: volumeSMAWindow,
|
||||
volumeMultiplier: volumeMultiplier,
|
||||
grayFake: grayFake,
|
||||
hideMarketClosedGaps: hideMarketClosedGaps,
|
||||
enableAutoRefresh: enableAutoRefresh,
|
||||
refreshSeconds: refreshSeconds
|
||||
)
|
||||
}
|
||||
|
||||
var queryItems: [URLQueryItem] {
|
||||
[
|
||||
URLQueryItem(name: "symbol", value: symbol),
|
||||
URLQueryItem(name: "interval", value: interval),
|
||||
URLQueryItem(name: "period", value: period),
|
||||
URLQueryItem(name: "max_bars", value: String(maxBars)),
|
||||
URLQueryItem(name: "drop_live", value: String(dropLive)),
|
||||
URLQueryItem(name: "use_body_range", value: String(useBodyRange)),
|
||||
URLQueryItem(name: "volume_filter_enabled", value: String(volumeFilterEnabled)),
|
||||
URLQueryItem(name: "volume_sma_window", value: String(volumeSMAWindow)),
|
||||
URLQueryItem(name: "volume_multiplier", value: String(volumeMultiplier)),
|
||||
URLQueryItem(name: "gray_fake", value: String(grayFake)),
|
||||
URLQueryItem(name: "hide_market_closed_gaps", value: String(hideMarketClosedGaps)),
|
||||
URLQueryItem(name: "enable_auto_refresh", value: String(enableAutoRefresh)),
|
||||
URLQueryItem(name: "refresh_sec", value: String(refreshSeconds)),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MobileContentView()
|
||||
}
|
||||
16
mac/src/AppTests/ManeshTraderMacTests.swift
Normal file
@ -0,0 +1,16 @@
|
||||
//
|
||||
// ManeshTraderMacTests
|
||||
//
|
||||
// Created by Matt Bruce on 2/13/26.
|
||||
//
|
||||
|
||||
import Testing
|
||||
@testable import ManeshTraderMac
|
||||
|
||||
struct ManeshTraderMacTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
41
mac/src/AppUITests/ManeshTraderMacUITests.swift
Normal file
@ -0,0 +1,41 @@
|
||||
//
|
||||
// ManeshTraderMacUITests.swift
|
||||
// ManeshTraderMacUITests
|
||||
//
|
||||
// Created by Matt Bruce on 2/13/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class ManeshTraderMacUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
33
mac/src/AppUITests/ManeshTraderMacUITestsLaunchTests.swift
Normal file
@ -0,0 +1,33 @@
|
||||
//
|
||||
// ManeshTraderMacUITestsLaunchTests.swift
|
||||
// ManeshTraderMacUITests
|
||||
//
|
||||
// Created by Matt Bruce on 2/13/26.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class ManeshTraderMacUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
704
mac/src/MacShell.xcodeproj/project.pbxproj
Normal file
@ -0,0 +1,704 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
EAB6607A2F3FD5C100ED41BA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = EAB660642F3FD5C000ED41BA /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = EAB6606B2F3FD5C000ED41BA;
|
||||
remoteInfo = ManeshTraderMac;
|
||||
};
|
||||
EAB660842F3FD5C100ED41BA /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = EAB660642F3FD5C000ED41BA /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = EAB6606B2F3FD5C000ED41BA;
|
||||
remoteInfo = ManeshTraderMac;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
EAB6606C2F3FD5C000ED41BA /* ManeshTraderMac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ManeshTraderMac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAB661002F3FD5C100ED41BA /* ManeshTraderMobile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ManeshTraderMobile.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAB660792F3FD5C100ED41BA /* ManeshTraderMacTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ManeshTraderMacTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
EAB660832F3FD5C100ED41BA /* ManeshTraderMacUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ManeshTraderMacUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
EAB6606E2F3FD5C000ED41BA /* App */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EAB661012F3FD5C100ED41BA /* AppMobile */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = AppMobile;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EAB6607C2F3FD5C100ED41BA /* AppTests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = AppTests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EAB660862F3FD5C100ED41BA /* AppUITests */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
path = AppUITests;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
EAB660692F3FD5C000ED41BA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB661032F3FD5C100ED41BA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB660762F3FD5C100ED41BA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB660802F3FD5C100ED41BA /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
EAB660632F3FD5C000ED41BA = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EAB6606E2F3FD5C000ED41BA /* App */,
|
||||
EAB661012F3FD5C100ED41BA /* AppMobile */,
|
||||
EAB6607C2F3FD5C100ED41BA /* AppTests */,
|
||||
EAB660862F3FD5C100ED41BA /* AppUITests */,
|
||||
EAB6606D2F3FD5C000ED41BA /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
EAB6606D2F3FD5C000ED41BA /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
EAB6606C2F3FD5C000ED41BA /* ManeshTraderMac.app */,
|
||||
EAB661002F3FD5C100ED41BA /* ManeshTraderMobile.app */,
|
||||
EAB660792F3FD5C100ED41BA /* ManeshTraderMacTests.xctest */,
|
||||
EAB660832F3FD5C100ED41BA /* ManeshTraderMacUITests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
EAB6606B2F3FD5C000ED41BA /* ManeshTraderMac */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAB6608D2F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMac" */;
|
||||
buildPhases = (
|
||||
EAB660682F3FD5C000ED41BA /* Sources */,
|
||||
EAB660692F3FD5C000ED41BA /* Frameworks */,
|
||||
EAB6606A2F3FD5C000ED41BA /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EAB6606E2F3FD5C000ED41BA /* App */,
|
||||
);
|
||||
name = ManeshTraderMac;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = ManeshTraderMac;
|
||||
productReference = EAB6606C2F3FD5C000ED41BA /* ManeshTraderMac.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EAB661022F3FD5C100ED41BA /* ManeshTraderMobile */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAB661082F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMobile" */;
|
||||
buildPhases = (
|
||||
EAB661052F3FD5C100ED41BA /* Sources */,
|
||||
EAB661032F3FD5C100ED41BA /* Frameworks */,
|
||||
EAB661042F3FD5C100ED41BA /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EAB661012F3FD5C100ED41BA /* AppMobile */,
|
||||
);
|
||||
name = ManeshTraderMobile;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = ManeshTraderMobile;
|
||||
productReference = EAB661002F3FD5C100ED41BA /* ManeshTraderMobile.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
EAB660782F3FD5C100ED41BA /* ManeshTraderMacTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAB660902F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMacTests" */;
|
||||
buildPhases = (
|
||||
EAB660752F3FD5C100ED41BA /* Sources */,
|
||||
EAB660762F3FD5C100ED41BA /* Frameworks */,
|
||||
EAB660772F3FD5C100ED41BA /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
EAB6607B2F3FD5C100ED41BA /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EAB6607C2F3FD5C100ED41BA /* AppTests */,
|
||||
);
|
||||
name = ManeshTraderMacTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = ManeshTraderMacTests;
|
||||
productReference = EAB660792F3FD5C100ED41BA /* ManeshTraderMacTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
EAB660822F3FD5C100ED41BA /* ManeshTraderMacUITests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = EAB660932F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMacUITests" */;
|
||||
buildPhases = (
|
||||
EAB6607F2F3FD5C100ED41BA /* Sources */,
|
||||
EAB660802F3FD5C100ED41BA /* Frameworks */,
|
||||
EAB660812F3FD5C100ED41BA /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
EAB660852F3FD5C100ED41BA /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
EAB660862F3FD5C100ED41BA /* AppUITests */,
|
||||
);
|
||||
name = ManeshTraderMacUITests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = ManeshTraderMacUITests;
|
||||
productReference = EAB660832F3FD5C100ED41BA /* ManeshTraderMacUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
EAB660642F3FD5C000ED41BA /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
EAB6606B2F3FD5C000ED41BA = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
EAB661022F3FD5C100ED41BA = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
};
|
||||
EAB660782F3FD5C100ED41BA = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = EAB6606B2F3FD5C000ED41BA;
|
||||
};
|
||||
EAB660822F3FD5C100ED41BA = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
TestTargetID = EAB6606B2F3FD5C000ED41BA;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = EAB660672F3FD5C000ED41BA /* Build configuration list for PBXProject "MacShell" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = EAB660632F3FD5C000ED41BA;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = EAB6606D2F3FD5C000ED41BA /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
EAB6606B2F3FD5C000ED41BA /* ManeshTraderMac */,
|
||||
EAB661022F3FD5C100ED41BA /* ManeshTraderMobile */,
|
||||
EAB660782F3FD5C100ED41BA /* ManeshTraderMacTests */,
|
||||
EAB660822F3FD5C100ED41BA /* ManeshTraderMacUITests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
EAB6606A2F3FD5C000ED41BA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB661042F3FD5C100ED41BA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB660772F3FD5C100ED41BA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB660812F3FD5C100ED41BA /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
EAB660682F3FD5C000ED41BA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB661052F3FD5C100ED41BA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB660752F3FD5C100ED41BA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
EAB6607F2F3FD5C100ED41BA /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
EAB6607B2F3FD5C100ED41BA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = EAB6606B2F3FD5C000ED41BA /* ManeshTraderMac */;
|
||||
targetProxy = EAB6607A2F3FD5C100ED41BA /* PBXContainerItemProxy */;
|
||||
};
|
||||
EAB660852F3FD5C100ED41BA /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = EAB6606B2F3FD5C000ED41BA /* ManeshTraderMac */;
|
||||
targetProxy = EAB660842F3FD5C100ED41BA /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
EAB6608B2F3FD5C100ED41BA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EAB6608C2F3FD5C100ED41BA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
EAB6608E2F3FD5C100ED41BA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EAB6608F2F3FD5C100ED41BA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = NO;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMac;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
EAB661062F3FD5C100ED41BA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EAB661072F3FD5C100ED41BA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMobile;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
EAB660912F3FD5C100ED41BA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMacTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ManeshTraderMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ManeshTraderMac";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EAB660922F3FD5C100ED41BA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMacTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ManeshTraderMac.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ManeshTraderMac";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
EAB660942F3FD5C100ED41BA /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMacUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = ManeshTraderMac;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
EAB660952F3FD5C100ED41BA /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 6R7KLBPBLZ;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.mbrucedogs.ManeshTraderMacUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = NO;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TEST_TARGET_NAME = ManeshTraderMac;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
EAB660672F3FD5C000ED41BA /* Build configuration list for PBXProject "MacShell" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EAB6608B2F3FD5C100ED41BA /* Debug */,
|
||||
EAB6608C2F3FD5C100ED41BA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
EAB6608D2F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMac" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EAB6608E2F3FD5C100ED41BA /* Debug */,
|
||||
EAB6608F2F3FD5C100ED41BA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
EAB661082F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMobile" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EAB661062F3FD5C100ED41BA /* Debug */,
|
||||
EAB661072F3FD5C100ED41BA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
EAB660902F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMacTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EAB660912F3FD5C100ED41BA /* Debug */,
|
||||
EAB660922F3FD5C100ED41BA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
EAB660932F3FD5C100ED41BA /* Build configuration list for PBXNativeTarget "ManeshTraderMacUITests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
EAB660942F3FD5C100ED41BA /* Debug */,
|
||||
EAB660952F3FD5C100ED41BA /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = EAB660642F3FD5C000ED41BA /* Project object */;
|
||||
}
|
||||
7
mac/src/MacShell.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Workspace
|
||||
version = "1.0">
|
||||
<FileRef
|
||||
location = "self:">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "2630"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EAB661022F3FD5C100ED41BA"
|
||||
BuildableName = "ManeshTraderMobile.app"
|
||||
BlueprintName = "ManeshTraderMobile"
|
||||
ReferencedContainer = "container:MacShell.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EAB661022F3FD5C100ED41BA"
|
||||
BuildableName = "ManeshTraderMobile.app"
|
||||
BlueprintName = "ManeshTraderMobile"
|
||||
ReferencedContainer = "container:MacShell.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "EAB661022F3FD5C100ED41BA"
|
||||
BuildableName = "ManeshTraderMobile.app"
|
||||
BlueprintName = "ManeshTraderMobile"
|
||||
ReferencedContainer = "container:MacShell.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
</Scheme>
|
||||
38
mac/src/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# macOS Shell App
|
||||
|
||||
Native macOS shell around the web app in `web/src/`.
|
||||
|
||||
## What It Does
|
||||
- Starts/stops bundled backend executable from app resources
|
||||
- Hosts UI in `WKWebView` at local `127.0.0.1` URL
|
||||
- Keeps user inside app window (no external browser)
|
||||
- Provides a native toolbar `Help` button that opens bundled quick-start docs in-app
|
||||
|
||||
## Build Self-Contained App
|
||||
From repo root:
|
||||
1. Build embedded backend + macOS app:
|
||||
- `./scripts/build_selfcontained_mac_app.sh`
|
||||
2. Output app bundle:
|
||||
- `dist-mac/<timestamp>/<Scheme>.app`
|
||||
3. Optional DMG packaging:
|
||||
- `APP_BUNDLE_PATH="dist-mac/<timestamp>/<Scheme>.app" ./scripts/create_installer_dmg.sh`
|
||||
- DMG output path: `build/dmg/<AppName>-<timestamp>.dmg`
|
||||
|
||||
## Run In Xcode
|
||||
From repo root:
|
||||
1. Generate embedded backend binary:
|
||||
- `./scripts/build_embedded_backend.sh`
|
||||
2. Open project:
|
||||
- `mac/src/*.xcodeproj`
|
||||
3. Build/Run scheme:
|
||||
- default: project name (or set `MAC_SCHEME` in scripts)
|
||||
|
||||
## iOS/iPadOS Target
|
||||
- New target: `ManeshTraderMobile` (same Xcode project: `mac/src/MacShell.xcodeproj`).
|
||||
- The mobile target is a web wrapper and does **not** launch the embedded backend executable.
|
||||
- In the app, open `Setup` and provide a backend URL reachable from the device/simulator (for example `http://<LAN-IP>:8501` or an HTTPS host).
|
||||
- The same symbol/timeframe/period preferences are passed as query params to the backend URL.
|
||||
|
||||
## Notes
|
||||
- Web source of truth is `web/src/` (`web/src/app.py`, `web/src/web_core/`).
|
||||
- Embedded backend binary is copied into the first `EmbeddedBackend/` folder discovered under the selected project directory.
|
||||
4
run.sh
Executable file
@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/web/run.sh" "$@"
|
||||
123
scripts/build_embedded_backend.sh
Executable file
@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WEB_SRC_DIR="$ROOT_DIR/web/src"
|
||||
MAC_SRC_DIR="${MAC_SRC_DIR:-$ROOT_DIR/mac/src}"
|
||||
PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
|
||||
LAUNCHER="$WEB_SRC_DIR/backend_embedded_launcher.py"
|
||||
BACKEND_BIN_NAME="${BACKEND_BIN_NAME:-WebBackend}"
|
||||
BUILD_ROOT="$ROOT_DIR/dist-backend-build"
|
||||
DIST_PATH="$BUILD_ROOT/dist"
|
||||
WORK_PATH="$BUILD_ROOT/build"
|
||||
SPEC_PATH="$BUILD_ROOT/spec"
|
||||
PROJECT_PATH="${MAC_PROJECT_PATH:-}"
|
||||
SCHEME="${MAC_SCHEME:-}"
|
||||
TARGET_DIR="${EMBEDDED_BACKEND_DIR:-}"
|
||||
|
||||
discover_scheme() {
|
||||
local project_path="$1"
|
||||
python3 - "$project_path" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
project = sys.argv[1]
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["xcodebuild", "-list", "-project", project, "-json"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
payload = json.loads(output)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
|
||||
project_data = payload.get("project", {})
|
||||
schemes = project_data.get("schemes") or []
|
||||
print(schemes[0] if schemes else "")
|
||||
PY
|
||||
}
|
||||
|
||||
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||
echo "Missing virtual environment. Run ./run.sh --setup-only first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f "$LAUNCHER" ]]; then
|
||||
echo "Missing launcher file: $LAUNCHER" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$PROJECT_PATH" ]]; then
|
||||
PROJECT_PATH="$(find "$MAC_SRC_DIR" -maxdepth 4 -name "*.xcodeproj" | sort | head -n 1)"
|
||||
fi
|
||||
if [[ -z "$PROJECT_PATH" ]]; then
|
||||
echo "No .xcodeproj found under: $MAC_SRC_DIR" >&2
|
||||
echo "Set MAC_PROJECT_PATH to the project you want to build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PROJECT_DIR="$(dirname "$PROJECT_PATH")"
|
||||
if [[ -z "$SCHEME" ]]; then
|
||||
SCHEME="$(discover_scheme "$PROJECT_PATH")"
|
||||
fi
|
||||
if [[ -z "$SCHEME" ]]; then
|
||||
SCHEME="$(basename "$PROJECT_PATH" .xcodeproj)"
|
||||
fi
|
||||
|
||||
if [[ -z "$TARGET_DIR" ]]; then
|
||||
DEFAULT_TARGET_DIR="$PROJECT_DIR/$SCHEME/EmbeddedBackend"
|
||||
if [[ -d "$DEFAULT_TARGET_DIR" ]]; then
|
||||
TARGET_DIR="$DEFAULT_TARGET_DIR"
|
||||
else
|
||||
TARGET_DIR="$(find "$PROJECT_DIR" -maxdepth 4 -type d -name EmbeddedBackend | sort | head -n 1)"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$TARGET_DIR" ]]; then
|
||||
TARGET_DIR="$PROJECT_DIR/$SCHEME/EmbeddedBackend"
|
||||
fi
|
||||
|
||||
mkdir -p "$DIST_PATH" "$WORK_PATH" "$SPEC_PATH" "$TARGET_DIR"
|
||||
|
||||
"$PYTHON_BIN" -m pip install -q pyinstaller
|
||||
|
||||
PYI_ARGS=(
|
||||
--noconfirm
|
||||
--clean
|
||||
--onefile
|
||||
--name "$BACKEND_BIN_NAME"
|
||||
--distpath "$DIST_PATH"
|
||||
--workpath "$WORK_PATH"
|
||||
--specpath "$SPEC_PATH"
|
||||
--add-data "$WEB_SRC_DIR/app.py:."
|
||||
--collect-all streamlit
|
||||
--collect-all streamlit_autorefresh
|
||||
--hidden-import yfinance
|
||||
--hidden-import pandas
|
||||
--collect-all plotly
|
||||
--collect-all kaleido
|
||||
)
|
||||
|
||||
for source_dir in "$WEB_SRC_DIR"/*; do
|
||||
source_name="$(basename "$source_dir")"
|
||||
if [[ ! -d "$source_dir" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$source_name" == "tests" ]] || [[ "$source_name" == "__pycache__" ]]; then
|
||||
continue
|
||||
fi
|
||||
PYI_ARGS+=(--add-data "$source_dir:$source_name")
|
||||
done
|
||||
|
||||
if [[ -f "$WEB_SRC_DIR/ONBOARDING.md" ]]; then
|
||||
PYI_ARGS+=(--add-data "$WEB_SRC_DIR/ONBOARDING.md:.")
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" -m PyInstaller "${PYI_ARGS[@]}" "$LAUNCHER"
|
||||
|
||||
cp "$DIST_PATH/$BACKEND_BIN_NAME" "$TARGET_DIR/$BACKEND_BIN_NAME"
|
||||
chmod +x "$TARGET_DIR/$BACKEND_BIN_NAME"
|
||||
|
||||
echo "Embedded backend updated: $TARGET_DIR/$BACKEND_BIN_NAME"
|
||||
52
scripts/build_fresh_installer_dmg.sh
Executable file
@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
SETUP_SCRIPT="$ROOT_DIR/run.sh"
|
||||
BUILD_APP_SCRIPT="$ROOT_DIR/scripts/build_selfcontained_mac_app.sh"
|
||||
BUILD_DMG_SCRIPT="$ROOT_DIR/scripts/create_installer_dmg.sh"
|
||||
VENV_PYTHON="$ROOT_DIR/.venv/bin/python"
|
||||
BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build}"
|
||||
DMG_DIR="${DMG_DIR:-$BUILD_DIR/dmg}"
|
||||
|
||||
if [[ ! -x "$VENV_PYTHON" ]]; then
|
||||
echo "Virtual environment missing. Running setup..."
|
||||
"$SETUP_SCRIPT" --setup-only
|
||||
fi
|
||||
|
||||
if ! command -v create-dmg >/dev/null 2>&1; then
|
||||
echo "create-dmg not found. Install with: brew install create-dmg" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
app_marker="$(mktemp)"
|
||||
touch "$app_marker"
|
||||
|
||||
echo "Building fresh self-contained app (includes embedded backend rebuild)..."
|
||||
"$BUILD_APP_SCRIPT"
|
||||
|
||||
APP_BUNDLE_PATH="$(find "$ROOT_DIR/dist-mac" -type d -name "*.app" -newer "$app_marker" | sort | tail -n 1 || true)"
|
||||
rm -f "$app_marker"
|
||||
|
||||
if [[ -z "$APP_BUNDLE_PATH" || ! -d "$APP_BUNDLE_PATH" ]]; then
|
||||
echo "Build succeeded but no freshly built app bundle was found in dist-mac." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
dmg_marker="$(mktemp)"
|
||||
touch "$dmg_marker"
|
||||
|
||||
echo "Packaging DMG from freshly built app:"
|
||||
echo " $APP_BUNDLE_PATH"
|
||||
APP_BUNDLE_PATH="$APP_BUNDLE_PATH" "$BUILD_DMG_SCRIPT"
|
||||
|
||||
DMG_PATH="$(find "$DMG_DIR" -type f -name "*.dmg" -newer "$dmg_marker" | sort | tail -n 1 || true)"
|
||||
rm -f "$dmg_marker"
|
||||
|
||||
if [[ -n "$DMG_PATH" && -f "$DMG_PATH" ]]; then
|
||||
echo "Fresh installer ready:"
|
||||
echo " $DMG_PATH"
|
||||
else
|
||||
echo "DMG packaging step completed, but new DMG path could not be auto-detected." >&2
|
||||
echo "Check: $DMG_DIR"
|
||||
fi
|
||||
73
scripts/build_selfcontained_mac_app.sh
Executable file
@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MAC_SRC_DIR="${MAC_SRC_DIR:-$ROOT_DIR/mac/src}"
|
||||
PROJECT_PATH="${MAC_PROJECT_PATH:-}"
|
||||
SCHEME="${MAC_SCHEME:-}"
|
||||
CONFIGURATION="${CONFIGURATION:-Release}"
|
||||
DERIVED_DATA_PATH="$ROOT_DIR/dist-mac/derived-data"
|
||||
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
|
||||
OUTPUT_DIR="$ROOT_DIR/dist-mac/$TIMESTAMP"
|
||||
|
||||
discover_scheme() {
|
||||
local project_path="$1"
|
||||
python3 - "$project_path" <<'PY'
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
project = sys.argv[1]
|
||||
try:
|
||||
output = subprocess.check_output(
|
||||
["xcodebuild", "-list", "-project", project, "-json"],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
payload = json.loads(output)
|
||||
except Exception:
|
||||
print("")
|
||||
raise SystemExit(0)
|
||||
|
||||
project_data = payload.get("project", {})
|
||||
schemes = project_data.get("schemes") or []
|
||||
print(schemes[0] if schemes else "")
|
||||
PY
|
||||
}
|
||||
|
||||
"$ROOT_DIR/scripts/build_embedded_backend.sh"
|
||||
|
||||
if [[ -z "$PROJECT_PATH" ]]; then
|
||||
PROJECT_PATH="$(find "$MAC_SRC_DIR" -maxdepth 4 -name "*.xcodeproj" | sort | head -n 1)"
|
||||
fi
|
||||
if [[ -z "$PROJECT_PATH" ]]; then
|
||||
echo "No .xcodeproj found under: $MAC_SRC_DIR" >&2
|
||||
echo "Set MAC_PROJECT_PATH to the project you want to build." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$SCHEME" ]]; then
|
||||
SCHEME="$(discover_scheme "$PROJECT_PATH")"
|
||||
fi
|
||||
if [[ -z "$SCHEME" ]]; then
|
||||
SCHEME="$(basename "$PROJECT_PATH" .xcodeproj)"
|
||||
fi
|
||||
|
||||
xcodebuild \
|
||||
-project "$PROJECT_PATH" \
|
||||
-scheme "$SCHEME" \
|
||||
-configuration "$CONFIGURATION" \
|
||||
-derivedDataPath "$DERIVED_DATA_PATH" \
|
||||
build
|
||||
|
||||
APP_PATH="$(find "$DERIVED_DATA_PATH/Build/Products/$CONFIGURATION" -maxdepth 2 -name "${SCHEME}.app" | head -n 1)"
|
||||
if [[ -z "${APP_PATH:-}" ]]; then
|
||||
echo "Build failed: ${SCHEME}.app not found in build products." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
cp -R "$APP_PATH" "$OUTPUT_DIR/"
|
||||
|
||||
echo "Self-contained app created: $OUTPUT_DIR/${SCHEME}.app"
|
||||
echo "To package DMG:"
|
||||
echo "APP_BUNDLE_PATH=\"$OUTPUT_DIR/${SCHEME}.app\" ./scripts/create_installer_dmg.sh"
|
||||
64
scripts/build_standalone_app.sh
Executable file
@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WEB_SRC_DIR="$ROOT_DIR/web/src"
|
||||
PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
|
||||
APP_NAME="${APP_NAME:-$(basename "$ROOT_DIR")}"
|
||||
|
||||
if [[ ! -x "$PYTHON_BIN" ]]; then
|
||||
echo "Missing virtual environment. Run ./run.sh --setup-only first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" -m pip install -q pyinstaller
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
OUT_ROOT="$ROOT_DIR/dist-standalone/$TS"
|
||||
DIST_PATH="$OUT_ROOT/dist"
|
||||
WORK_PATH="$OUT_ROOT/build"
|
||||
SPEC_PATH="$OUT_ROOT/spec"
|
||||
|
||||
mkdir -p "$DIST_PATH" "$WORK_PATH" "$SPEC_PATH"
|
||||
|
||||
PYI_ARGS=(
|
||||
--noconfirm
|
||||
--windowed
|
||||
--name "$APP_NAME"
|
||||
--distpath "$DIST_PATH"
|
||||
--workpath "$WORK_PATH"
|
||||
--specpath "$SPEC_PATH"
|
||||
--add-data "$WEB_SRC_DIR/app.py:."
|
||||
--collect-all streamlit
|
||||
--collect-all streamlit_autorefresh
|
||||
--hidden-import yfinance
|
||||
--hidden-import pandas
|
||||
--collect-all plotly
|
||||
)
|
||||
|
||||
for source_dir in "$WEB_SRC_DIR"/*; do
|
||||
source_name="$(basename "$source_dir")"
|
||||
if [[ ! -d "$source_dir" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "$source_name" == "tests" ]] || [[ "$source_name" == "__pycache__" ]]; then
|
||||
continue
|
||||
fi
|
||||
PYI_ARGS+=(--add-data "$source_dir:$source_name")
|
||||
done
|
||||
|
||||
if [[ -f "$WEB_SRC_DIR/ONBOARDING.md" ]]; then
|
||||
PYI_ARGS+=(--add-data "$WEB_SRC_DIR/ONBOARDING.md:.")
|
||||
fi
|
||||
|
||||
"$PYTHON_BIN" -m PyInstaller "${PYI_ARGS[@]}" "$WEB_SRC_DIR/desktop_launcher.py"
|
||||
|
||||
APP_BUNDLE="$DIST_PATH/${APP_NAME}.app"
|
||||
if [[ ! -d "$APP_BUNDLE" ]]; then
|
||||
echo "Build failed: ${APP_BUNDLE} not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Standalone app created: $APP_BUNDLE"
|
||||
echo "To build DMG from this app:"
|
||||
echo "APP_BUNDLE_PATH=\"$APP_BUNDLE\" ./scripts/create_installer_dmg.sh"
|
||||
71
scripts/create_installer_dmg.sh
Executable file
@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
find_latest_app_bundle() {
|
||||
local candidate
|
||||
|
||||
candidate="$(find "$ROOT_DIR/dist-mac" -type d -name "*.app" 2>/dev/null | sort | tail -n 1 || true)"
|
||||
if [[ -n "$candidate" ]]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
candidate="$(find "$ROOT_DIR/dist-standalone" -type d -name "*.app" 2>/dev/null | sort | tail -n 1 || true)"
|
||||
if [[ -n "$candidate" ]]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
candidate="$(find "$ROOT_DIR" -maxdepth 1 -type d -name "*.app" | sort | head -n 1 || true)"
|
||||
if [[ -n "$candidate" ]]; then
|
||||
echo "$candidate"
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
APP_BUNDLE="${APP_BUNDLE_PATH:-}"
|
||||
if [[ -z "$APP_BUNDLE" ]]; then
|
||||
APP_BUNDLE="$(find_latest_app_bundle || true)"
|
||||
fi
|
||||
|
||||
if ! command -v create-dmg >/dev/null 2>&1; then
|
||||
echo "create-dmg not found. Install with: brew install create-dmg" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$APP_BUNDLE" ]]; then
|
||||
echo "App bundle not found." >&2
|
||||
echo "Set APP_BUNDLE_PATH to a built .app bundle or build one first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_FILENAME="$(basename "$APP_BUNDLE")"
|
||||
APP_NAME="${APP_FILENAME%.app}"
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
BUILD_DIR="${BUILD_DIR:-$ROOT_DIR/build}"
|
||||
DMG_DIR="${DMG_DIR:-$BUILD_DIR/dmg}"
|
||||
STAGE_DIR="$DMG_DIR/stage/$TS"
|
||||
OUT_DMG="$DMG_DIR/${APP_NAME}-$TS.dmg"
|
||||
|
||||
mkdir -p "$DMG_DIR" "$STAGE_DIR"
|
||||
cp -R "$APP_BUNDLE" "$STAGE_DIR/"
|
||||
|
||||
create-dmg \
|
||||
--volname "${APP_NAME} Installer" \
|
||||
--window-size 600 400 \
|
||||
--icon-size 120 \
|
||||
--icon "$APP_FILENAME" 175 190 \
|
||||
--icon "Applications" 425 190 \
|
||||
--hide-extension "$APP_FILENAME" \
|
||||
--app-drop-link 425 190 \
|
||||
"$OUT_DMG" \
|
||||
"$STAGE_DIR"
|
||||
|
||||
rm -rf "$STAGE_DIR"
|
||||
rmdir "$DMG_DIR/stage" 2>/dev/null || true
|
||||
|
||||
echo "Created installer: $OUT_DMG"
|
||||
30
scripts/create_mac_app.sh
Executable file
@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
APP_NAME="${APP_NAME:-$(basename "$ROOT_DIR")}"
|
||||
APP_PATH="$ROOT_DIR/${APP_NAME}.app"
|
||||
|
||||
if ! command -v osacompile >/dev/null 2>&1; then
|
||||
echo "Error: osacompile is not available on this macOS installation." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ESCAPED_ROOT="${ROOT_DIR//\"/\\\"}"
|
||||
SCRIPT_FILE="$(mktemp)"
|
||||
|
||||
cat > "$SCRIPT_FILE" <<EOF
|
||||
on run
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "cd \"$ESCAPED_ROOT\" && ./run.sh"
|
||||
end tell
|
||||
end run
|
||||
EOF
|
||||
|
||||
rm -rf "$APP_PATH"
|
||||
osacompile -o "$APP_PATH" "$SCRIPT_FILE"
|
||||
rm -f "$SCRIPT_FILE"
|
||||
|
||||
echo "Created: $APP_PATH"
|
||||
echo "You can drag ${APP_NAME}.app into /Applications if desired."
|
||||
129
scripts/generate_app_icon.py
Normal file
@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFilter
|
||||
|
||||
|
||||
def lerp(a: float, b: float, t: float) -> float:
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def make_gradient(size: int) -> Image.Image:
|
||||
img = Image.new("RGBA", (size, size), (0, 0, 0, 255))
|
||||
px = img.load()
|
||||
top = (8, 17, 40)
|
||||
bottom = (17, 54, 95)
|
||||
for y in range(size):
|
||||
t = y / (size - 1)
|
||||
color = tuple(int(lerp(top[i], bottom[i], t)) for i in range(3)) + (255,)
|
||||
for x in range(size):
|
||||
px[x, y] = color
|
||||
return img
|
||||
|
||||
|
||||
def rounded_rect_mask(size: int, radius: int) -> Image.Image:
|
||||
m = Image.new("L", (size, size), 0)
|
||||
d = ImageDraw.Draw(m)
|
||||
d.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
|
||||
return m
|
||||
|
||||
|
||||
def draw_icon(size: int = 1024) -> Image.Image:
|
||||
base = make_gradient(size)
|
||||
draw = ImageDraw.Draw(base)
|
||||
|
||||
# Soft vignette
|
||||
vignette = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
vd = ImageDraw.Draw(vignette)
|
||||
vd.ellipse((-size * 0.25, -size * 0.15, size * 1.25, size * 1.15), fill=(255, 255, 255, 26))
|
||||
vd.ellipse((-size * 0.1, size * 0.55, size * 1.1, size * 1.5), fill=(0, 0, 0, 70))
|
||||
vignette = vignette.filter(ImageFilter.GaussianBlur(radius=size * 0.06))
|
||||
base = Image.alpha_composite(base, vignette)
|
||||
draw = ImageDraw.Draw(base)
|
||||
|
||||
# Grid lines
|
||||
grid_color = (190, 215, 255, 34)
|
||||
margin = int(size * 0.16)
|
||||
for i in range(1, 5):
|
||||
y = int(lerp(margin, size - margin, i / 5))
|
||||
draw.line((margin, y, size - margin, y), fill=grid_color, width=max(1, size // 512))
|
||||
|
||||
# Candlestick bodies/wicks
|
||||
center_x = size // 2
|
||||
widths = int(size * 0.09)
|
||||
gap = int(size * 0.05)
|
||||
|
||||
candles = [
|
||||
(center_x - widths - gap, 0.62, 0.33, 0.57, 0.36, (18, 214, 130, 255)),
|
||||
(center_x, 0.72, 0.40, 0.45, 0.65, (255, 88, 88, 255)),
|
||||
(center_x + widths + gap, 0.58, 0.30, 0.52, 0.34, (18, 214, 130, 255)),
|
||||
]
|
||||
|
||||
for x, low, high, body_top, body_bottom, color in candles:
|
||||
x = int(x)
|
||||
y_low = int(size * low)
|
||||
y_high = int(size * high)
|
||||
y_a = int(size * body_top)
|
||||
y_b = int(size * body_bottom)
|
||||
y_top = min(y_a, y_b)
|
||||
y_bottom = max(y_a, y_b)
|
||||
wick_w = max(3, size // 180)
|
||||
draw.line((x, y_high, x, y_low), fill=(220, 235, 255, 220), width=wick_w)
|
||||
bw = widths
|
||||
draw.rounded_rectangle(
|
||||
(x - bw // 2, y_top, x + bw // 2, y_bottom),
|
||||
radius=max(6, size // 64),
|
||||
fill=color,
|
||||
)
|
||||
|
||||
# Trend arrows
|
||||
arrow_green = (45, 237, 147, 255)
|
||||
arrow_red = (255, 77, 77, 255)
|
||||
up = [
|
||||
(int(size * 0.20), int(size * 0.70)),
|
||||
(int(size * 0.29), int(size * 0.61)),
|
||||
(int(size * 0.25), int(size * 0.61)),
|
||||
(int(size * 0.25), int(size * 0.52)),
|
||||
(int(size * 0.15), int(size * 0.52)),
|
||||
(int(size * 0.15), int(size * 0.61)),
|
||||
(int(size * 0.11), int(size * 0.61)),
|
||||
]
|
||||
down = [
|
||||
(int(size * 0.80), int(size * 0.34)),
|
||||
(int(size * 0.71), int(size * 0.43)),
|
||||
(int(size * 0.75), int(size * 0.43)),
|
||||
(int(size * 0.75), int(size * 0.52)),
|
||||
(int(size * 0.85), int(size * 0.52)),
|
||||
(int(size * 0.85), int(size * 0.43)),
|
||||
(int(size * 0.89), int(size * 0.43)),
|
||||
]
|
||||
draw.polygon(up, fill=arrow_green)
|
||||
draw.polygon(down, fill=arrow_red)
|
||||
|
||||
# Rounded-square icon mask
|
||||
mask = rounded_rect_mask(size, radius=int(size * 0.23))
|
||||
out = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
out.paste(base, (0, 0), mask)
|
||||
|
||||
# Subtle border
|
||||
bd = ImageDraw.Draw(out)
|
||||
bd.rounded_rectangle(
|
||||
(2, 2, size - 3, size - 3),
|
||||
radius=int(size * 0.23),
|
||||
outline=(255, 255, 255, 44),
|
||||
width=max(2, size // 256),
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> None:
|
||||
out_path = Path("assets/icon/ManeshTrader.png")
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
img = draw_icon(1024)
|
||||
img.save(out_path)
|
||||
print(f"Wrote {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
90
skills/macos-selfcontained-webapp/SKILL.md
Normal file
@ -0,0 +1,90 @@
|
||||
---
|
||||
name: macos-selfcontained-webapp
|
||||
description: "Build or refactor a project so a web app runs as a self-contained macOS app: embedded local backend binary + SwiftUI/WKWebView shell + packaging scripts. Use when a user has web logic and wants no external browser, no separate web-code install, local execution from inside .app, and repeatable DMG packaging."
|
||||
---
|
||||
|
||||
# macOS Self-Contained Web App
|
||||
|
||||
## Outcome
|
||||
Deliver one installable macOS app that:
|
||||
- launches an embedded backend executable from app resources
|
||||
- opens the web UI only inside `WKWebView`
|
||||
- persists settings locally and supports native-to-web sync
|
||||
- provides native app-level help/onboarding (not only in the web UI)
|
||||
- can be packaged as a DMG
|
||||
|
||||
## Layout Standard
|
||||
Use this layout for new projects:
|
||||
- `web/src/` for web backend source (`app.py`, modules, requirements)
|
||||
- `mac/src/` for Xcode project and SwiftUI shell sources
|
||||
- `scripts/` for build and packaging automation
|
||||
- `docs/` for architecture and onboarding docs
|
||||
|
||||
For existing repos, avoid destructive renames unless explicitly requested. Add compatibility paths or migration commits in small steps.
|
||||
|
||||
Detailed naming guidance: `references/layout-and-naming.md`
|
||||
|
||||
## Workflow
|
||||
1. Inventory existing architecture.
|
||||
- Identify backend entrypoint, runtime dependencies, and static/resource files.
|
||||
- Confirm current launch mode and where browser auto-open is happening.
|
||||
|
||||
2. Create embedded backend launcher.
|
||||
- Add a thin launcher (`backend_embedded_launcher.py` or equivalent) that starts the web server on `127.0.0.1` and reads port from env (for example `MANESH_TRADER_PORT`).
|
||||
- Ensure browser auto-open is disabled.
|
||||
|
||||
3. Build backend into a single executable.
|
||||
- Add `scripts/build_embedded_backend.sh` that compiles backend into a one-file executable and copies it into the mac target resource folder.
|
||||
- Use generic discovery:
|
||||
- discover `*.xcodeproj` under `mac/src`
|
||||
- discover scheme via `xcodebuild -list -json` (fallback to project name)
|
||||
- discover/create `EmbeddedBackend` folder under selected project sources
|
||||
- Include all required web files/modules in build inputs.
|
||||
- Default backend name should be stable (for example `WebBackend`) and configurable via env.
|
||||
|
||||
4. Implement SwiftUI host shell.
|
||||
- Use `@Observable` host state (no `ObservableObject`/Combine).
|
||||
- Start/stop backend process, detect available local port, and handle retries.
|
||||
- Render URL in `WKWebView` only.
|
||||
- Keep app responsive when backend fails and surface actionable status.
|
||||
- Resolve backend executable by checking both current and legacy names during migration.
|
||||
- Add a native toolbar `Help` button that opens bundled help content in-app.
|
||||
|
||||
5. Define settings sync contract.
|
||||
- Use shared settings file (for example `~/.<app>/settings.json`) as source of truth.
|
||||
- Normalize settings both in web app and native app.
|
||||
- Pass effective settings via URL query params on launch/reload/restart.
|
||||
- Keep onboarding limited to starter fields; preserve advanced fields.
|
||||
- Add web-only fallback access to help/onboarding (for users not using the mac wrapper).
|
||||
|
||||
6. Automate packaging.
|
||||
- Add `scripts/build_selfcontained_mac_app.sh` to build embedded backend then Xcode app.
|
||||
- Add `scripts/create_installer_dmg.sh` for distributable DMG.
|
||||
- Use generic app bundle discovery for DMG defaults where possible.
|
||||
- Standardize installer artifacts under `build/dmg/` (not repo root).
|
||||
- Ensure `.gitignore` excludes generated artifacts (`build/`, `*.dmg`, temp `rw.*.dmg`).
|
||||
|
||||
7. Validate.
|
||||
- Python syntax: `python -m py_compile` on web entrypoint.
|
||||
- Tests: `PYTHONPATH=web/src pytest -q web/src/tests`.
|
||||
- Xcode build: `xcodebuild ... build`.
|
||||
- Runtime check: no external browser opens, webview loads locally, settings persist across relaunch.
|
||||
- Verify help works in both modes:
|
||||
- native toolbar help popup in mac app
|
||||
- sidebar help fallback in web-only mode
|
||||
|
||||
## Required Deliverables
|
||||
- Embedded backend build script
|
||||
- macOS app host that launches backend + WKWebView
|
||||
- Shared settings sync path
|
||||
- Native help/onboarding popup + web fallback help entry
|
||||
- README section for local build + DMG workflow
|
||||
- Verified build commands and final artifact locations
|
||||
- `.gitignore` updated for build/installer outputs
|
||||
|
||||
## References
|
||||
- Layout and migration rules: `references/layout-and-naming.md`
|
||||
- Implementation blueprint and command templates: `references/implementation-blueprint.md`
|
||||
|
||||
## Bundled Script
|
||||
Use `scripts/scaffold_web_mac_layout.sh` to create a new standardized folder skeleton for fresh projects.
|
||||
4
skills/macos-selfcontained-webapp/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "macOS Self-Contained Web App"
|
||||
short_description: "Embed a local web backend in a self-contained macOS WKWebView app."
|
||||
default_prompt: "Create or refactor this project into a self-contained macOS app wrapper with web/src + mac/src layout, embedded backend binary, native Help popup, DMG output in build/dmg, and proper .gitignore rules."
|
||||
@ -0,0 +1,99 @@
|
||||
# Implementation Blueprint
|
||||
|
||||
## Backend Build Script (Python Example)
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WEB_SRC_DIR="$ROOT_DIR/web/src"
|
||||
MAC_SRC_DIR="${MAC_SRC_DIR:-$ROOT_DIR/mac/src}"
|
||||
PYTHON_BIN="$ROOT_DIR/.venv/bin/python"
|
||||
BACKEND_BIN_NAME="${BACKEND_BIN_NAME:-WebBackend}"
|
||||
BUILD_ROOT="$ROOT_DIR/dist-backend-build"
|
||||
DIST_PATH="$BUILD_ROOT/dist"
|
||||
WORK_PATH="$BUILD_ROOT/build"
|
||||
SPEC_PATH="$BUILD_ROOT/spec"
|
||||
PROJECT_PATH="${MAC_PROJECT_PATH:-}"
|
||||
SCHEME="${MAC_SCHEME:-}"
|
||||
TARGET_DIR="${EMBEDDED_BACKEND_DIR:-}"
|
||||
|
||||
if [[ -z "$PROJECT_PATH" ]]; then
|
||||
PROJECT_PATH="$(find "$MAC_SRC_DIR" -maxdepth 4 -name "*.xcodeproj" | sort | head -n 1)"
|
||||
fi
|
||||
PROJECT_DIR="$(dirname "$PROJECT_PATH")"
|
||||
if [[ -z "$TARGET_DIR" ]]; then
|
||||
TARGET_DIR="$(find "$PROJECT_DIR" -maxdepth 4 -type d -name EmbeddedBackend | head -n 1)"
|
||||
fi
|
||||
mkdir -p "$DIST_PATH" "$WORK_PATH" "$SPEC_PATH" "$TARGET_DIR"
|
||||
|
||||
"$PYTHON_BIN" -m pip install -q pyinstaller
|
||||
|
||||
"$PYTHON_BIN" -m PyInstaller \
|
||||
--noconfirm --clean --onefile \
|
||||
--name "$BACKEND_BIN_NAME" \
|
||||
--distpath "$DIST_PATH" \
|
||||
--workpath "$WORK_PATH" \
|
||||
--specpath "$SPEC_PATH" \
|
||||
--add-data "$WEB_SRC_DIR/app.py:." \
|
||||
--add-data "$WEB_SRC_DIR/<module_dir>:<module_dir>" \
|
||||
"$WEB_SRC_DIR/backend_embedded_launcher.py"
|
||||
|
||||
cp "$DIST_PATH/$BACKEND_BIN_NAME" "$TARGET_DIR/$BACKEND_BIN_NAME"
|
||||
chmod +x "$TARGET_DIR/$BACKEND_BIN_NAME"
|
||||
```
|
||||
|
||||
## SwiftUI Host Requirements
|
||||
- Use `@Observable` host object.
|
||||
- Compute `serverURL` from host/port + query items.
|
||||
- Start backend once on appear; stop on disappear.
|
||||
- Render backend URL in `WKWebView`.
|
||||
- Retry provisional load failure after short delay.
|
||||
- Keep debug controls behind `#if DEBUG`.
|
||||
- Add toolbar Help action that opens a bundled local `help.html` in a sheet/webview.
|
||||
|
||||
## Settings Sync Contract
|
||||
- Shared path: `~/.<app>/settings.json`
|
||||
- Normalize every field on web and native sides.
|
||||
- Load shared file before app launch.
|
||||
- Push normalized fields as query params when launching/reloading webview.
|
||||
- Persist setup-sheet changes back to shared file.
|
||||
- Keep a legacy fallback path for migration when needed.
|
||||
|
||||
## Packaging Commands
|
||||
- Build app:
|
||||
|
||||
```bash
|
||||
./scripts/build_selfcontained_mac_app.sh
|
||||
```
|
||||
|
||||
- Create DMG:
|
||||
|
||||
```bash
|
||||
APP_BUNDLE_PATH="dist-mac/<timestamp>/<AppName>Mac.app" ./scripts/create_installer_dmg.sh
|
||||
```
|
||||
|
||||
Expected DMG output:
|
||||
|
||||
```text
|
||||
build/dmg/<AppName>-<timestamp>.dmg
|
||||
```
|
||||
|
||||
## Git Ignore Baseline
|
||||
|
||||
```gitignore
|
||||
build/
|
||||
*.dmg
|
||||
rw.*.dmg
|
||||
```
|
||||
|
||||
## Verification Checklist
|
||||
- No external browser window opens.
|
||||
- App starts with embedded backend from app resources.
|
||||
- `WKWebView` loads local URL.
|
||||
- Settings persist across relaunch and remain in sync.
|
||||
- Native Help popup renders bundled content.
|
||||
- Web-only run has sidebar help fallback.
|
||||
- DMG installs and runs on a second machine.
|
||||
- DMG is produced under `build/dmg/` and repo root stays clean.
|
||||
@ -0,0 +1,72 @@
|
||||
# Layout And Naming
|
||||
|
||||
## Canonical Structure
|
||||
|
||||
```text
|
||||
<repo-root>/
|
||||
web/
|
||||
run.sh
|
||||
src/
|
||||
app.py
|
||||
<web modules>
|
||||
requirements.txt
|
||||
ONBOARDING.md
|
||||
mac/
|
||||
src/
|
||||
<Project>.xcodeproj/
|
||||
App/
|
||||
ContentView.swift
|
||||
<AppMain>.swift
|
||||
EmbeddedBackend/
|
||||
WebBackend
|
||||
Help/
|
||||
help.html
|
||||
AppTests/
|
||||
AppUITests/
|
||||
scripts/
|
||||
build_embedded_backend.sh
|
||||
build_selfcontained_mac_app.sh
|
||||
create_installer_dmg.sh
|
||||
build/
|
||||
dmg/
|
||||
<AppName>-<timestamp>.dmg
|
||||
docs/
|
||||
architecture.md
|
||||
```
|
||||
|
||||
## Naming Rules
|
||||
- Repo: lowercase hyphenated (`trader-desktop-shell`).
|
||||
- macOS target/scheme: PascalCase (`TraderMac` or project-defined), but source folders should stay generic (`App`, `AppTests`, `AppUITests`).
|
||||
- Embedded backend binary: stable generic default (`WebBackend`) with env override support.
|
||||
- Settings directory: lowercase snake or kebab (`~/.trader_app`).
|
||||
- Environment port var: uppercase snake (keep legacy fallbacks during migration).
|
||||
|
||||
## Script Discovery Rules
|
||||
- Build scripts should discover the first `*.xcodeproj` under `mac/src` unless `MAC_PROJECT_PATH` is provided.
|
||||
- Build scripts should discover scheme via `xcodebuild -list -json` unless `MAC_SCHEME` is provided.
|
||||
- Embedded backend target dir should be derived from selected project sources or `EMBEDDED_BACKEND_DIR`.
|
||||
- Python tests should run with `PYTHONPATH=web/src`.
|
||||
- DMG scripts should write to `build/dmg/` and clean temporary staging folders.
|
||||
|
||||
## Ignore Rules
|
||||
Add these to root `.gitignore`:
|
||||
- `build/`
|
||||
- `*.dmg`
|
||||
- `rw.*.dmg`
|
||||
|
||||
## Migration Rules For Existing Projects
|
||||
1. Do not rename everything in one commit.
|
||||
2. First, add new folders and compatibility references.
|
||||
3. Move scripts and docs next.
|
||||
4. Move web source only after build scripts are updated.
|
||||
5. Move mac source into `mac/src/App*` before optional target/project renames.
|
||||
6. Add compatibility lookup for old backend binary names and old settings paths during transition.
|
||||
|
||||
## Commit Strategy
|
||||
1. `chore(layout): add canonical folders`
|
||||
2. `build(backend): add embedded binary build`
|
||||
3. `feat(mac-shell): host local backend in webview`
|
||||
4. `feat(sync): add shared settings contract`
|
||||
5. `feat(help): add native help popup + web fallback`
|
||||
6. `build(packaging): add self-contained app + dmg scripts`
|
||||
7. `chore(rename): finalize naming migration`
|
||||
99
skills/macos-selfcontained-webapp/scripts/scaffold_web_mac_layout.sh
Executable file
@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -lt 1 ]]; then
|
||||
echo "Usage: $0 <repo-root> [app-name]"
|
||||
echo "Example: $0 ~/Code/trader-app Trader"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$1"
|
||||
APP_NAME="${2:-WebShellApp}"
|
||||
|
||||
mkdir -p "$REPO_ROOT/web/src"
|
||||
mkdir -p "$REPO_ROOT/web/src/web_core"
|
||||
mkdir -p "$REPO_ROOT/web/src/tests"
|
||||
mkdir -p "$REPO_ROOT/mac/src/App/EmbeddedBackend"
|
||||
mkdir -p "$REPO_ROOT/mac/src/App/Help"
|
||||
mkdir -p "$REPO_ROOT/mac/src/AppTests"
|
||||
mkdir -p "$REPO_ROOT/mac/src/AppUITests"
|
||||
mkdir -p "$REPO_ROOT/scripts"
|
||||
mkdir -p "$REPO_ROOT/docs"
|
||||
|
||||
cat > "$REPO_ROOT/docs/architecture.md" <<DOC
|
||||
# ${APP_NAME} Architecture
|
||||
|
||||
- web backend source: ./web/src
|
||||
- mac shell source: ./mac/src
|
||||
- embedded backend binary: ./mac/src/App/EmbeddedBackend/WebBackend
|
||||
- native help page: ./mac/src/App/Help/help.html
|
||||
DOC
|
||||
|
||||
cat > "$REPO_ROOT/scripts/README.build.md" <<DOC
|
||||
# Build Script Placeholders
|
||||
|
||||
Add:
|
||||
- build_embedded_backend.sh
|
||||
- build_selfcontained_mac_app.sh
|
||||
- create_installer_dmg.sh
|
||||
|
||||
Suggested script behaviors:
|
||||
- Discover \`*.xcodeproj\` under \`mac/src\` unless \`MAC_PROJECT_PATH\` is provided.
|
||||
- Discover scheme via \`xcodebuild -list -json\` unless \`MAC_SCHEME\` is provided.
|
||||
- Default backend binary name: \`WebBackend\` (override with \`BACKEND_BIN_NAME\`).
|
||||
- Write DMG artifacts to \`build/dmg/\`.
|
||||
DOC
|
||||
|
||||
cat > "$REPO_ROOT/web/src/ONBOARDING.md" <<DOC
|
||||
# ${APP_NAME} Onboarding
|
||||
|
||||
Add your web help/onboarding content here.
|
||||
DOC
|
||||
|
||||
cat > "$REPO_ROOT/mac/src/App/Help/help.html" <<DOC
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Help</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${APP_NAME} Help</h1>
|
||||
<p>Replace this with your quick start and onboarding content.</p>
|
||||
</body>
|
||||
</html>
|
||||
DOC
|
||||
|
||||
cat > "$REPO_ROOT/web/run.sh" <<'DOC'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
WEB_SRC_DIR="$ROOT_DIR/web/src"
|
||||
|
||||
if [[ ! -d "$VENV_DIR" ]]; then
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_DIR/bin/activate"
|
||||
pip install -r "$WEB_SRC_DIR/requirements.txt"
|
||||
exec streamlit run "$WEB_SRC_DIR/app.py"
|
||||
DOC
|
||||
chmod +x "$REPO_ROOT/web/run.sh"
|
||||
|
||||
touch "$REPO_ROOT/.gitignore"
|
||||
for line in "build/" "*.dmg" "rw.*.dmg"; do
|
||||
if ! grep -Fxq "$line" "$REPO_ROOT/.gitignore"; then
|
||||
printf "%s\n" "$line" >> "$REPO_ROOT/.gitignore"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Created layout for ${APP_NAME}:"
|
||||
echo "- $REPO_ROOT/web/src"
|
||||
echo "- $REPO_ROOT/mac/src"
|
||||
echo "- $REPO_ROOT/scripts"
|
||||
echo "- $REPO_ROOT/docs"
|
||||
echo "- $REPO_ROOT/.gitignore (updated with build/DMG ignore rules)"
|
||||
33
web/run.sh
Executable file
@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
WEB_SRC_DIR="$ROOT_DIR/web/src"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
SETUP_ONLY=false
|
||||
|
||||
if [[ "${1:-}" == "--setup-only" ]]; then
|
||||
SETUP_ONLY=true
|
||||
fi
|
||||
|
||||
if [[ ! -d "$VENV_DIR" ]]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv "$VENV_DIR"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
source "$VENV_DIR/bin/activate"
|
||||
|
||||
if [[ ! -f "$VENV_DIR/.deps_installed" ]] || [[ "$WEB_SRC_DIR/requirements.txt" -nt "$VENV_DIR/.deps_installed" ]]; then
|
||||
echo "Installing dependencies..."
|
||||
pip install -r "$WEB_SRC_DIR/requirements.txt"
|
||||
touch "$VENV_DIR/.deps_installed"
|
||||
fi
|
||||
|
||||
if [[ "$SETUP_ONLY" == "true" ]]; then
|
||||
echo "Setup complete."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Starting Streamlit app..."
|
||||
exec streamlit run "$WEB_SRC_DIR/app.py"
|
||||
@ -2,8 +2,7 @@ FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@ -263,7 +263,7 @@ If PDF fails, ensure `kaleido` is installed (already in `requirements.txt`).
|
||||
|
||||
### Port already in use
|
||||
```bash
|
||||
streamlit run app.py --server.port 8501
|
||||
streamlit run app.py --server.port 8502
|
||||
```
|
||||
|
||||
### Bad symbol/data error
|
||||
11
web/src/docker-compose.yml
Normal file
@ -0,0 +1,11 @@
|
||||
services:
|
||||
maneshtrader:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: maneshtrader
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8501:8501"
|
||||
volumes:
|
||||
- ./data:/root/.web_local_shell
|
||||