Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>
13
Makefile
@ -1,13 +0,0 @@
|
|||||||
.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
@ -1,63 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
cd "$(dirname "$0")"
|
|
||||||
./run.sh
|
|
||||||
@ -1,391 +0,0 @@
|
|||||||
#!/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
|
|
||||||
25
docker-compose.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
maneshtrader:
|
||||||
|
image: python:3.11-slim
|
||||||
|
container_name: maneshtrader
|
||||||
|
working_dir: /app
|
||||||
|
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:
|
||||||
|
- ./:/app
|
||||||
|
- ./data:/root/.web_local_shell
|
||||||
|
command: >
|
||||||
|
sh -c "pip install -r requirements.txt &&
|
||||||
|
streamlit run app.py --server.address=0.0.0.0 --server.port=8501 --server.headless=true"
|
||||||
|
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
|
||||||
@ -1,345 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 792 B |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 76 KiB |
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,867 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
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.
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
//
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"colors" : [
|
|
||||||
{
|
|
||||||
"idiom" : "universal"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"info" : {
|
|
||||||
"author" : "xcode",
|
|
||||||
"version" : 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,196 +0,0 @@
|
|||||||
<!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>
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
@main
|
|
||||||
struct ManeshTraderMobileApp: App {
|
|
||||||
var body: some Scene {
|
|
||||||
WindowGroup {
|
|
||||||
MobileContentView()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,615 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
//
|
|
||||||
// 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.
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
//
|
|
||||||
// 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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
//
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,704 +0,0 @@
|
|||||||
// !$*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 */;
|
|
||||||
}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<Workspace
|
|
||||||
version = "1.0">
|
|
||||||
<FileRef
|
|
||||||
location = "self:">
|
|
||||||
</FileRef>
|
|
||||||
</Workspace>
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
<?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>
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
# 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
@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/web/run.sh" "$@"
|
|
||||||
@ -1,123 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@ -1,52 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@ -1,129 +0,0 @@
|
|||||||
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()
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
---
|
|
||||||
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.
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
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."
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
#!/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
@ -1,33 +0,0 @@
|
|||||||
#!/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"
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
services:
|
|
||||||
maneshtrader:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: maneshtrader
|
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
|
||||||
- "8501:8501"
|
|
||||||
volumes:
|
|
||||||
- ./data:/root/.web_local_shell
|
|
||||||