1
0
mirror of https://github.com/EDCD/EDMarketConnector.git synced 2025-04-17 01:22:19 +03:00

Merge branch 'beta'

This commit is contained in:
Athanasius 2022-02-15 15:55:05 +00:00
commit 173300c45c
No known key found for this signature in database
GPG Key ID: AE3E527847057C7D
53 changed files with 4001 additions and 2597 deletions

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
# This is the project top-level .editorconfig
root = true
# Defaults for all file types
[*]
# 4-space indents, no TABs
indent_style = space
tab_width = 4
indent_size = tab
# Hard-wrap at 120 columns
max_line_length = 119
# Windows EOL, for historical reasons
end_of_line = crlf
# UTF-8 is the only sensible option, no BOM
charset = utf-8
# All files should have a final newline
insert_final_newline = true

View File

@ -52,10 +52,10 @@ jobs:
####################################################################
# Get Python set up
####################################################################
- name: Set up Python 3.9
uses: actions/setup-python@v2.3.0
- name: Set up Python 3.10
uses: actions/setup-python@v2.3.2
with:
python-version: 3.9
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

View File

@ -20,10 +20,10 @@ jobs:
- uses: actions/checkout@v2.4.0
with:
fetch-depth: 0
- name: Set up Python 3.9
uses: actions/setup-python@v2.3.0
- name: Set up Python 3.10
uses: actions/setup-python@v2.3.2
with:
python-version: 3.9
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip

82
.github/workflows/submodule-update.yml vendored Normal file
View File

@ -0,0 +1,82 @@
---
name: Submodule Updates
on:
# We might want this on a schedule once this is in `main`
push:
branches: [ develop ]
jobs:
check_submodules:
name: Pull Request for updated submodules
runs-on: ubuntu-latest
env:
PARENT_REPOSITORY: 'EDCD/EDMarketConnector'
CHECKOUT_BRANCH: 'develop'
PR_AGAINST_BRANCH: 'develop'
OWNER: 'EDCD'
steps:
- uses: actions/checkout@v2.4.0
with:
submodules: true
- name: Update submodules
shell: bash
run: |
git config user.name github-actions
git config user.email github-actions@github.com
git submodule update --remote
- name: Check for changes
id: check_for_changes
continue-on-error: true
run: |
changes=$(git status --porcelain)
if [ -n "${changes}" ];
then
echo '::set-output changes=true'
fi
echo '::set-output changes=false'
exit 0
- name: Create submodules changes branch
if: steps.check_for_changes.outputs.changes == 'true'
run: |
git checkout -b $GITHUB_RUN_ID
git commit -am "updating submodules"
git push --set-upstream origin $GITHUB_RUN_ID
- name: Create pull request against target branch
if: steps.check_for_changes.outputs.changes == 'true'
uses: actions/github-script@v6
with:
github-token: ${{ inputs.github_token }}
script: |
await github.rest.pulls.create({
owner: '${{ inputs.owner }}',
repo: '${{ inputs.parent_repository }}'.split('/')[1].trim(),
head: process.env.GITHUB_RUN_ID,
base: '${{ inputs.pr_against_branch }}',
title: `[Auto-generated] Submodule Updates ${process.env.GITHUB_RUN_ID}`,
body: `[Auto-generated] Submodule Updates ${process.env.GITHUB_RUN_ID}`,
});
- name: Add labels
if: steps.check_for_changes.outputs.changes == 'true'
uses: actions/github-script@v6
with:
github-token: ${{ inputs.github_token }}
script: |
const res = await github.rest.issues.listForRepo({
owner: '${{ inputs.owner }}',
repo: '${{ inputs.parent_repository }}'.split('/')[1].trim(),
});
const pr = res.data.filter(i => i.title.includes(process.env.GITHUB_RUN_ID));
const prNumber = pr[0].number;
await github.rest.issues.addLabels({
issue_number: prNumber,
owner: '${{ inputs.owner }}',
repo: '${{ inputs.parent_repository }}'.split('/')[1].trim(),
labels: ['${{ inputs.label }}']
});

View File

@ -17,9 +17,12 @@ jobs:
steps:
- uses: actions/checkout@v2.4.0
- uses: actions/setup-python@v2.3.0
with:
python-version: "3.9.9"
submodules: true
- uses: actions/setup-python@v2.3.2
with:
python-version: "3.10.2"
architecture: "x86"
- name: Install python tools

2
.gitignore vendored
View File

@ -23,3 +23,5 @@ venv
htmlcov/
.ignored
.coverage
EDMarketConnector.wxs
wix/components.wxs

3
.gitmodules vendored
View File

@ -1,3 +1,6 @@
[submodule "coriolis-data"]
path = coriolis-data
url = git@github.com:EDCD/coriolis-data.git
[submodule "FDevIDs"]
path = FDevIDs
url = https://github.com/EDCD/FDevIDs.git

View File

@ -2,3 +2,4 @@
follow_imports = skip
ignore_missing_imports = True
scripts_are_modules = True
; platform = darwin

View File

@ -1,13 +1,19 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
# This can be used to check how pre-commit is being run
# - repo: meta
# hooks:
# - id: identity
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 'v3.4.0'
rev: 'v4.1.0'
hooks:
- id: check-merge-conflict
- id: debug-statements
- id: end-of-file-fixer
- id: mixed-line-ending
- id: check-yaml
#- repo: https://github.com/pre-commit/mirrors-autopep8
# rev: ''
@ -30,7 +36,7 @@ repos:
types: [ python ]
- repo: https://github.com/pre-commit/pygrep-hooks
rev: 'v1.8.0'
rev: 'v1.9.0'
hooks:
- id: python-no-eval
- id: python-no-log-warn
@ -40,10 +46,13 @@ repos:
# mypy - static type checking
# mypy --follow-imports skip <file>
- repo: https://github.com/pre-commit/mirrors-mypy
rev: 'v0.812'
rev: 'v0.931'
hooks:
- id: mypy
args: [ "--follow-imports", "skip", "--ignore-missing-imports", "--scripts-are-modules" ]
# verbose: true
# log_file: 'pre-commit_mypy.log'
additional_dependencies: [ types-requests ]
# args: [ "--follow-imports", "skip", "--ignore-missing-imports", "--scripts-are-modules" ]
### # pydocstyle.exe <file>
### - repo: https://github.com/FalconSocial/pre-commit-mirrors-pep257
@ -59,12 +68,13 @@ repos:
# safety.exe check -r requirements.txt
- repo: https://github.com/Lucas-C/pre-commit-hooks-safety
rev: 'v1.2.1'
rev: 'v1.2.3'
hooks:
- id: python-safety-dependencies-check
entry: safety
args: [check, --bare, -r]
args: [check, --bare, --file]
language: system
files: requirements-dev.txt, requirements.txt
# Check translation comments are up to date
- repo: local
@ -77,7 +87,7 @@ repos:
always_run: true
default_language_version:
python: python3.9
python: python3.10
default_stages: [ commit, push ]

View File

@ -1 +1 @@
3.9.9
3.10.2

View File

@ -9,11 +9,11 @@ produce the Windows executables and installer.
---
* We now test against, and package with, Python 3.9.9.
* We now test against, and package with, Python 3.10.2.
**As a consequence of this we no longer support Windows 7.
This is due to
[Python 3.9.x itself not supporting Windows 7](https://www.python.org/downloads/windows/).
[Python 3.10.x itself not supporting Windows 7](https://www.python.org/downloads/windows/).
The application (both EDMarketConnector.exe and EDMC.exe) will crash on
startup due to a missing DLL.**
@ -27,18 +27,243 @@ produce the Windows executables and installer.
---
Release 5.3.0
===
As has sadly become routine now, please read
[our statement about malware false positives](https://github.com/EDCD/EDMarketConnector/wiki/Troubleshooting#installer-and-or-executables-flagged-as-malicious-viruses)
affecting our installers and/or the files they contain. We are as confident
as we can be, without detailed auditing of python.org's releases and all
the py2exe source and releases, that there is no malware in the files we make
available.
This release is primarily aimed at fixing some more egregious bugs,
shortcomings and annoyances with the application. It also adds support for
two additional
[EDDN](https://github.com/EDCD/EDDN/blob/live/README.md)
schemas.
* We now test and build using Python 3.10.2. We do not *yet* make use of any
features specific to Python 3.10 (or 3.9). Let us restate that we
absolutely reserve the right to commence doing so.
* We now set a custom User-Agent header in all web requests, i.e. to EDDN,
EDSM and the like. This is of the form:
`EDCD-EDMarketConnector-<version>`
* "File" -> "Status" will now show the new Odyssey ranks, both the new
categories and the new 'prestige' ranks, e.g. 'Elite I'.
**NB: Due to an oversight there are currently no translations for these.**
Closes [#1369](https://github.com/EDCD/EDMarketConnector/issues/1369).
* Running `EDMarketConnector.exe --reset-ui` will now also reset any changes to
the application "UI Scale" or geometry (position and size).
Closes [#1155](https://github.com/EDCD/EDMarketConnector/issues/1155).
* We now use UTC-based timestamps in the application's log files. Prior to
this change it was the "local time", but without any indication of the
applied timezone. Each line's timestamp has ` UTC` as a suffix now. We
are assuming that your local clock is correct *and* the timezone is set
correctly, such that Python's `time.gmtime()` yields UTC times.
This should make it easier to correlate application logfiles with in-game
time and/or third-party service timestamps.
* The process used to build the Windows installers should now always pick up
all the necessary files automatically. Prior to this we used a manual
process to update the installer configuration which was prone to both user
error and neglecting to update it as necessary.
* If the application fails to load valid data from the `NavRoute.json` file
when processing a Journal `NavRoute` event, it will attempt to retry this
operation a number of times as it processes subsequent Journal events.
This should hopefully work around a race condition where the game might
not have yet updated `NavRoute.json` at all, or has truncated it to empty,
when we first attempt this.
We will also now *NOT* attempt to load `NavRoute.json` during the startup
'Journal catch-up' mode, which only sets internal state.
Closes [#1348](https://github.com/EDCD/EDMarketConnector/issues/1155).
* Inara: Use the `<journal log>->Statistics->Bank_Account->Current_Wealth`
value when sending a `setCommanderCredits` message to Inara to set
`commanderAssets`.
In addition, a `setCommanderCredits` message at game login **will now only
ever be sent at game login**. Yes, you will **NEED** to relog to send an
updated balance. This is the only way in which to sanely keep the
'Total Assets' value on Inara from bouncing around.
Refer to [Inara:API:docs:setCommanderCredits](https://inara.cz/inara-api-docs/#event-1).
Closes [#1401](https://github.com/EDCD/EDMarketConnector/issues/1401).
* Inara: Send a `setCommanderRankPilot` message when the player logs in to the
game on-foot. Previously you would *HAVE* to be in a ship at login time
for this to be sent.
Thus, you can now relog on-foot in order to update Inara with any Rank up
or progress since the session started.
Closes [#1378](https://github.com/EDCD/EDMarketConnector/issues/1378).
* Inara: Fix for always sending a Rank Progress of 0%.
Closes [#1378](https://github.com/EDCD/EDMarketConnector/issues/1378).
* Inara: You should once more see updates for any materials used in
Engineering. The bug was in our more general Journal event processing
code pertaining to `EngineerCraft` events, such that the state passed to
the Inara plugin hadn't been updated.
Such updates should happen 'immediately', but take into account that there
can be a delay of up to 35 seconds for any data sent to Inara, due to how
we avoid breaking the "2 messages a minute" limit on the Inara API.
Closes [#1395](https://github.com/EDCD/EDMarketConnector/issues/1395).
* EDDN: Implement new [approachsettlement/1](https://github.com/EDCD/EDDN/blob/live/schemas/approachsettlement-README.md)
schema.
* EDDN: Implement new [fssallbodiesfound/1](https://github.com/EDCD/EDDN/blob/live/schemas/fssallbodiesfound-README.md)
schema.
* EDDN: We now compress all outgoing messages. This might help get some
particularly large `navroute` messages go through.
If any message is now rejected as 'too large' we will drop it, and thus
not retry it later. The application logs will reflect this.
NB: The EDDN Gateway was updated to allow messages up to 1 MiB in size
anyway. The old limit was 100 KiB.
Closes [#1390](https://github.com/EDCD/EDMarketConnector/issues/1390).
* EDDN: In an attempt to diagnose some errors observed on the EDDN Gateway
with respect to messages sent from this application some additional checks
and logging have been added.
**NB: After some thorough investigation it was concluded that these EDDN
errors were likely the result of long-delayed messages due to use of
the "Delay sending until docked" option.**
There should be no functional changes for users. But if you see any of
the following in this application's log files **PLEASE OPEN
[AN ISSUE ON GITHUB](https://github.com/EDCD/EDMarketConnector/issues/new?assignees=&labels=bug%2C+unconfirmed&template=bug_report.md&title=)
with all the requested information**, so that we can correct the relevant
code:
- `No system name in entry, and system_name was not set either! entry: ...`
- `BodyName was present but not a string! ...`
- `post-processing entry contains entry ...`
- `this.body_id was not set properly: ...`
- `system is falsey, can't add StarSystem`
- `this.coordinates is falsey, can't add StarPos`
- `this.systemaddress is falsey, can't add SystemAddress`
- `this.status_body_name was not set properly: ...`
You might also see any of the following in the application status text
(bottom of the window):
- `passed-in system_name is empty, can't add System`
- `CodexEntry had empty string, PLEASE ALERT THE EDMC DEVELOPERS`
- `system is falsey, can't add StarSystem`
- `this.coordinates is falsey, can't add StarPos`
- `this.systemaddress is falsey, can't add SystemAddress`
Ref: [#1403](https://github.com/EDCD/EDMarketConnector/issues/1403)
[#1393](https://github.com/EDCD/EDMarketConnector/issues/1393).
Translations
---
* Use a different workaround for OneSky (translations website) using "zh-Hans"
for Chinese (Simplified), whereas Windows will call this "zh-CN". This is
in-code and documented with a comment, as opposed to some 'magic' in the
Windows Installer configuration that had no such documentation. It's less
fragile than relying on that, or developers using a script/documented
process to rename the file.
* As noted above we forgot to upload to
[OneSky](https://marginal.oneskyapp.com/collaboration/project/52710)
after adding the Odyssey new ranks/categories. This has now been done,
and some new phrases await translation.
Plugin Developers
---
We now test against, and package with Python 3.10.2.
* We've made no explicit changes to the Python stdlib, or other modules, we
currently offer, but we did have to start explicitly including
`asyncio` and `multiprocessing` due to using a newer version of `py2exe`
for the windows build.
* We will now include in the Windows installer *all* of the files that `py2exe`
places in the build directory. This is vulnerable to a later version of
our code, python and/or py2exe no longer causing inclusion of a module.
We have endeavoured to ensure this release contains *at least* all of the
same modules that 5.2.4 did.
We are looking into
[including all of Python stdlib](https://github.com/EDCD/EDMarketConnector/issues/1327),
but if there's a particular part of this we don't package then please ask
us to by opening an issue on GitHub.
* We now have an `.editorconfig` file which will instruct your editor/IDE to
change some settings pertaining to things like indentation and line wrap,
assuming your editor/IDE supports the file.
See [Contributing.md->Text formatting](Contributing.md#text-formatting).
* As noted above, prior to this version we weren't properly monitoring
`EngineerCraft` events. This caused the `state` passed to plugins to not
contain the correct 'materials' (Raw, Manufactured, Encoded) counts.
* `config.py` has been refactored into a sub-directory, with the per-OS code
split into separate files. There *shouldn't* be any changes necessary to
how you utilise this, e.g. to determine the application version.
All forms of any `import` statement that worked before should have
unchanged functionality.
* We now include [FDevIDS](https://github.com/EDCD/FDevIDs) as a
sub-repository, and use its files directly for keeping some game data up to
date. This should hopefully mean we include, e.g. new ships and modules
for loadout exports in a more timely manner.
Developers of third-party plugins should never have been using these files
anyway, so this shouldn't break anything for them.
* It's unlikely to affect you, but our `requirements-dev.txt` now explicitly
cites a specific version of `setuptools`. This was necessary to ensure we
have a version that works with `py2exe` for the windows build process.
If anything this will ensure you have a *more up to date* version of
`setuptools` installed.
---
---
Release 5.2.4
===
This is a *very* minor update that simply imports the latest versions of
This is a *very* minor update that simply imports the latest versions of
data files so that some niche functionality works properly.
* Update `commodity.csv` and `rare_commodity.csv` from the latest
[EDCD/FDevIDs](https://github.com/EDCD/FDevIDs) versions. This addresses
[EDCD/FDevIDs](https://github.com/EDCD/FDevIDs) versions. This addresses
an issue with export of market data in Trade Dangerous format containing
`OnionHeadC` rather than the correct name, `Onionhead Gamma Strain`, that
`OnionHeadC` rather than the correct name, `Onionhead Gamma Strain`, that
Trade Dangerous is expecting.
This will only have affected Trade Dangerous users who use EDMarketConnector
This will only have affected Trade Dangerous users who use EDMarketConnector
as a source of market data.
---

View File

@ -23,6 +23,16 @@ consistent with our vision for EDMC. Fundamental changes in particular need to b
---
## Text formatting
The project contains an `.editorconfig` file at its root. Please either ensure
your editor is taking note of those settings, or cross-check its contents
with the
[editorconfig documentation](https://github.com/editorconfig/editorconfig/wiki/EditorConfig-Properties)
, and ensure your editor/IDE's settings match.
---
## General workflow
1. You will need a GitHub account.
@ -181,9 +191,10 @@ only the 'C' (Patch) component.
Going forwards we will always use the full [Semantic Version](https://semver.org/#semantic-versioning-specification-semver)
and 'folder style' tag names, e.g. `Release/Major.Minor.Patch`.
Currently the only file that defines the version code-wise is `config.py`.
`Changelog.md` and `edmarketconnector.xml` are another matter handled as part
of [the release process](docs/Releasing.md#distribution).
Currently, the only file that defines the version code-wise is
`config/__init__.py`. `Changelog.md` and `edmarketconnector.xml` are another
matter handled as part of
[the release process](docs/Releasing.md#distribution).
---
@ -206,12 +217,18 @@ re-introduce a bug down the line.
We use the [`pytest`](https://docs.pytest.org/en/stable/) for unit testing.
The files for a test should go in a sub-directory of `tests/` named after the
(principal) file that contains the code they are testing. e.g. for
journal_lock.py the tests are in `tests/journal_lock.py/test_journal_lock.py`.
The `test_` prefix on `test_journal_lock.py` is necessary in order for `pytest`
to recognise the file as containing tests to be run.
(principal) file or directory that contains the code they are testing.
For example:
- Tests for `journal_lock.py` are in
`tests/journal_lock.py/test_journal_lock.py`. The `test_` prefix on
`test_journal_lock.py` is necessary in order for `pytest` to recognise the
file as containing tests to be run.
- Tests for `config/` code are located in `tests/config/test_config.py`, not
`tests/config.py/test_config.py`
The sub-directory avoids having a mess of files in `tests`, particularly when
there might be supporting files, e.g. `tests/config.py/_old_config.py` or files
there might be supporting files, e.g. `tests/config/_old_config.py` or files
containing test data.
Invoking just a bare `pytest` command will run all tests.
@ -228,6 +245,42 @@ handy if you want to step through the testing code to be sure of anything.
Otherwise, see the [pytest documentation](https://docs.pytest.org/en/stable/contents.html).
---
## Imports used only in core plugins
Because the 'core' plugins, as with any EDMarketConnector plugin, are only ever
loaded dynamically, not through an explicit `import` statement, there is no
way for `py2exe` to know about them when building the contents of the
`dist.win32` directory. See [docs/Releasing.md](docs/Releasing.md) for more
information about this build process.
Thus, you **MUST** check if any imports you add in `plugins/*.py` files are only
referenced in that file (or also only in any other core plugin), and if so
**YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
IN ORDER TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
INSTALLATION ON WINDOWS.**
An exmaple is that as of 2022-02-01 it was noticed that `plugins/eddn.py` now
uses `util/text.py`, and is the only code to do so. `py2exe` does not detect
this and thus the resulting `dist.win32/library.zip` does not contain the
`util/` directory, let alone the `util/text.py` file. The fix was to update
the appropriate `packages` definition to:
```python
'packages': [
'sqlite3', # Included for plugins
'util', # 2022-02-01 only imported in plugins/eddn.py
],
```
Note that in this case it's in `packages` because we want the whole directory
adding. For a single file an extra item in `includes` would suffice.
Such additions to `setup.py` should not cause any issues if subsequent project
changes cause `py2exe` to automatically pick up the same file(s).
---
## Debugging network sends
Rather than risk sending bad data to a remote service, even if only through
@ -484,6 +537,20 @@ Please be verbose here, more info about weird choices is always prefered over ma
Additionally, if your hack is over around 5 lines, please include a `# HACK END` or similar comment to indicate the end of the hack.
# Use `sys.platform` for platform guards
`mypy` (and `pylance`) understand platform guards and will show unreachable code / resolve imports correctly
for platform specific things. However, this only works if you directly reference `sys.platform`, importantly
the following does not work:
```py
from sys import platform
if platform == 'darwin':
...
```
It **MUST** be `if sys.platform`.
---
## Build process

View File

@ -46,6 +46,7 @@ from fnmatch import fnmatch
# So that any warning about accessing a protected member is only in one place.
from sys import _getframe as getframe
from threading import get_native_id as thread_native_id
from time import gmtime
from traceback import print_exc
from typing import TYPE_CHECKING, Tuple, cast
@ -91,6 +92,12 @@ logging.Logger.trace = lambda self, message, *args, **kwargs: self._log( # type
**kwargs
)
# MAGIC n/a | 2022-01-20: We want logging timestamps to be in UTC, not least because the game journals log in UTC.
# MAGIC-CONT: Note that the game client uses the ED server's idea of UTC, which can easily be different from machine
# MAGIC-CONT: local idea of it. So don't expect our log timestamps to perfectly match Journal ones.
# MAGIC-CONT: See MAGIC tagged comment in Logger.__init__()
logging.Formatter.converter = gmtime
def _trace_if(self: logging.Logger, condition: str, message: str, *args, **kwargs) -> None:
if any(fnmatch(condition, p) for p in config_mod.trace_on):
@ -162,7 +169,11 @@ class Logger:
self.logger_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(process)d:%(thread)d:%(osthreadid)d %(module)s.%(qualname)s:%(lineno)d: %(message)s') # noqa: E501
self.logger_formatter.default_time_format = '%Y-%m-%d %H:%M:%S'
self.logger_formatter.default_msec_format = '%s.%03d'
# MAGIC n/a | 2022-01-20: As of Python 3.10.2 you can *not* use either `%s.%03.d` in default_time_format
# MAGIC-CONT: (throws exceptions), *or* use `%Z` in default_time_msec (more exceptions).
# MAGIC-CONT: ' UTC' is hard-coded here - we know we're using the local machine's idea of UTC/GMT because we
# MAGIC-CONT: cause logging.Formatter() to use `gmtime()` - see MAGIC comment in this file's top-level code.
self.logger_formatter.default_msec_format = '%s.%03d UTC'
self.logger_channel.setFormatter(self.logger_formatter)
self.logger.addHandler(self.logger_channel)

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Entry point for the main GUI application."""
from __future__ import annotations
import argparse
import html
@ -14,7 +15,6 @@ import webbrowser
from builtins import object, str
from os import chdir, environ
from os.path import dirname, join
from sys import platform
from time import localtime, strftime, time
from typing import TYPE_CHECKING, Optional, Tuple, Union
@ -23,7 +23,7 @@ from typing import TYPE_CHECKING, Optional, Tuple, Union
# place for things like config.py reading .gitversion
if getattr(sys, 'frozen', False):
# Under py2exe sys.path[0] is the executable name
if platform == 'win32':
if sys.platform == 'win32':
chdir(dirname(sys.path[0]))
# Allow executable to be invoked from any cwd
environ['TCL_LIBRARY'] = join(dirname(sys.path[0]), 'lib', 'tcl')
@ -74,7 +74,7 @@ if __name__ == '__main__': # noqa: C901
###########################################################################
parser.add_argument(
'--reset-ui',
help='reset UI theme and transparency to defaults',
help='Reset UI theme, transparency, font, font size, ui scale, and ui geometry to default',
action='store_true'
)
###########################################################################
@ -234,7 +234,7 @@ if __name__ == '__main__': # noqa: C901
"""Handle any edmc:// auth callback, else foreground existing window."""
logger.trace_if('frontier-auth.windows', 'Begin...')
if platform == 'win32':
if sys.platform == 'win32':
# If *this* instance hasn't locked, then another already has and we
# now need to do the edmc:// checks for auth callback
@ -370,8 +370,9 @@ if __name__ == '__main__': # noqa: C901
# isort: off
if TYPE_CHECKING:
from logging import TRACE # type: ignore # noqa: F401 # Needed to update mypy
import update
from infi.systray import SysTrayIcon
if sys.platform == 'win32':
from infi.systray import SysTrayIcon
# isort: on
def _(x: str) -> str:
@ -443,7 +444,7 @@ class AppWindow(object):
self.prefsdialog = None
if platform == 'win32':
if sys.platform == 'win32':
from infi.systray import SysTrayIcon
def open_window(systray: 'SysTrayIcon') -> None:
@ -456,8 +457,8 @@ class AppWindow(object):
plug.load_plugins(master)
if platform != 'darwin':
if platform == 'win32':
if sys.platform != 'darwin':
if sys.platform == 'win32':
self.w.wm_iconbitmap(default='EDMarketConnector.ico')
else:
@ -527,7 +528,7 @@ class AppWindow(object):
# LANG: Update button in main window
self.button = ttk.Button(frame, text=_('Update'), width=28, default=tk.ACTIVE, state=tk.DISABLED)
self.theme_button = tk.Label(frame, width=32 if platform == 'darwin' else 28, state=tk.DISABLED)
self.theme_button = tk.Label(frame, width=32 if sys.platform == 'darwin' else 28, state=tk.DISABLED)
self.status = tk.Label(frame, name='status', anchor=tk.W)
ui_row = frame.grid_size()[1]
@ -540,14 +541,15 @@ class AppWindow(object):
theme.button_bind(self.theme_button, self.capi_request_data)
for child in frame.winfo_children():
child.grid_configure(padx=self.PADX, pady=(platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
child.grid_configure(padx=self.PADX, pady=(
sys.platform != 'win32' or isinstance(child, tk.Frame)) and 2 or 0)
# The type needs defining for adding the menu entry, but won't be
# properly set until later
self.updater: update.Updater = None
self.menubar = tk.Menu()
if platform == 'darwin':
if sys.platform == 'darwin':
# Can't handle (de)iconify if topmost is set, so suppress iconify button
# http://wiki.tcl.tk/13428 and p15 of
# https://developer.apple.com/legacy/library/documentation/Carbon/Conceptual/HandlingWindowsControls/windowscontrols.pdf
@ -603,7 +605,7 @@ class AppWindow(object):
self.help_menu.add_command(command=lambda: not self.HelpAbout.showing and self.HelpAbout(self.w))
self.menubar.add_cascade(menu=self.help_menu)
if platform == 'win32':
if sys.platform == 'win32':
# Must be added after at least one "real" menu entry
self.always_ontop = tk.BooleanVar(value=bool(config.get_int('always_ontop')))
self.system_menu = tk.Menu(self.menubar, name='system', tearoff=tk.FALSE)
@ -674,11 +676,11 @@ class AppWindow(object):
if config.get_str('geometry'):
match = re.match(r'\+([\-\d]+)\+([\-\d]+)', config.get_str('geometry'))
if match:
if platform == 'darwin':
if sys.platform == 'darwin':
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if int(match.group(2)) >= 0:
self.w.geometry(config.get_str('geometry'))
elif platform == 'win32':
elif sys.platform == 'win32':
# Check that the titlebar will be at least partly on screen
import ctypes
from ctypes.wintypes import POINT
@ -776,7 +778,7 @@ class AppWindow(object):
self.suit_shown = True
if not self.suit_shown:
if platform != 'win32':
if sys.platform != 'win32':
pady = 2
else:
@ -826,7 +828,7 @@ class AppWindow(object):
self.system_label['text'] = _('System') + ':' # LANG: Label for 'System' line in main UI
self.station_label['text'] = _('Station') + ':' # LANG: Label for 'Station' line in main UI
self.button['text'] = self.theme_button['text'] = _('Update') # LANG: Update button in main window
if platform == 'darwin':
if sys.platform == 'darwin':
self.menubar.entryconfigure(1, label=_('File')) # LANG: 'File' menu title on OSX
self.menubar.entryconfigure(2, label=_('Edit')) # LANG: 'Edit' menu title on OSX
self.menubar.entryconfigure(3, label=_('View')) # LANG: 'View' menu title on OSX
@ -873,7 +875,7 @@ class AppWindow(object):
self.button['state'] = self.theme_button['state'] = tk.DISABLED
if platform == 'darwin':
if sys.platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.DISABLED) # Status
self.file_menu.entryconfigure(0, state=tk.DISABLED) # Save Raw Data
@ -887,7 +889,7 @@ class AppWindow(object):
# LANG: Successfully authenticated with the Frontier website
self.status['text'] = _('Authentication successful')
if platform == 'darwin':
if sys.platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -1211,7 +1213,7 @@ class AppWindow(object):
companion.session.invalidate()
self.login()
except companion.ServerConnectionError as e:
except companion.ServerConnectionError as e: # TODO: unreachable (subclass of ServerLagging -- move to above)
logger.warning(f'Exception while contacting server: {e}')
err = self.status['text'] = str(e)
play_bad = True
@ -1429,7 +1431,7 @@ class AppWindow(object):
companion.session.auth_callback()
# LANG: Successfully authenticated with the Frontier website
self.status['text'] = _('Authentication successful')
if platform == 'darwin':
if sys.platform == 'darwin':
self.view_menu.entryconfigure(0, state=tk.NORMAL) # Status
self.file_menu.entryconfigure(0, state=tk.NORMAL) # Save Raw Data
@ -1570,11 +1572,11 @@ class AppWindow(object):
# position over parent
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if platform != 'darwin' or parent.winfo_rooty() > 0:
if sys.platform != 'darwin' or parent.winfo_rooty() > 0:
self.geometry(f'+{parent.winfo_rootx():d}+{parent.winfo_rooty():d}')
# remove decoration
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
self.resizable(tk.FALSE, tk.FALSE)
@ -1651,7 +1653,7 @@ class AppWindow(object):
"""
default_extension: str = ''
if platform == 'darwin':
if sys.platform == 'darwin':
default_extension = '.json'
timestamp: str = strftime('%Y-%m-%dT%H.%M.%S', localtime())
@ -1676,7 +1678,7 @@ class AppWindow(object):
def onexit(self, event=None) -> None:
"""Application shutdown procedure."""
if platform == 'win32':
if sys.platform == 'win32':
shutdown_thread = threading.Thread(target=self.systray.shutdown)
shutdown_thread.setDaemon(True)
shutdown_thread.start()
@ -1684,7 +1686,7 @@ class AppWindow(object):
config.set_shutdown() # Signal we're in shutdown now.
# http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if platform != 'darwin' or self.w.winfo_rooty() > 0:
if sys.platform != 'darwin' or self.w.winfo_rooty() > 0:
x, y = self.w.geometry().split('+')[1:3] # e.g. '212x170+2881+1267'
config.set('geometry', f'+{x}+{y}')
@ -1865,9 +1867,13 @@ sys.path: {sys.path}'''
if args.reset_ui:
config.set('theme', 0) # 'Default' theme uses ID 0
config.set('ui_transparency', 100) # 100 is completely opaque
config.delete('font')
config.delete('font_size')
logger.info('reset theme, font, font size, and transparency to default.')
config.delete('font', suppress=True)
config.delete('font_size', suppress=True)
config.set('ui_scale', 100) # 100% is the default here
config.delete('geometry', suppress=True) # unset is recreated by other code
logger.info('reset theme, transparency, font, font size, ui scale, and ui geometry to default.')
# We prefer a UTF-8 encoding gets set, but older Windows versions have
# issues with this. From Windows 10 1903 onwards we can rely on the

1
FDevIDs Submodule

@ -0,0 +1 @@
Subproject commit 9ca62008e93a98dd146bf37cb1e94f2f6c5d2fbf

View File

@ -1,38 +1,125 @@
/* Language name */
"!Language" = "English";
/* companion.py: Frontier CAPI didn't respond; In files: companion.py:220; */
"Error: Frontier CAPI didn't respond" = "Error: Frontier CAPI didn't respond";
/* eddn.py: Status text shown while attempting to send data; In files: eddn.py:212; eddn.py:584; eddn.py:925; load.py:215; load.py:593; load.py:940; eddn.py:252; eddn.py:699; eddn.py:1439; */
"Sending data to EDDN..." = "Sending data to EDDN...";
/* companion.py: Frontier CAPI data doesn't agree with latest Journal game location; In files: companion.py:239; */
"Error: Frontier server is lagging" = "Error: Frontier server is lagging";
/* eddn.py: Error while trying to send data to EDDN; In files: eddn.py:257; eddn.py:864; eddn.py:898; eddn.py:937; load.py:260; load.py:878; load.py:913; load.py:952; eddn.py:316; eddn.py:1375; eddn.py:1410; eddn.py:1451; */
"Error: Can't connect to EDDN" = "Error: Can't connect to EDDN";
/* companion.py: Commander is docked at an EDO settlement, got out and back in, we forgot the station; In files: companion.py:255; */
"Docked but unknown station: EDO Settlement?" = "Docked but unknown station: EDO Settlement?";
/* eddn.py: Enable EDDN support for station data checkbox label; In files: eddn.py:663; load.py:672; eddn.py:1122; */
"Send station data to the Elite Dangerous Data Network" = "Send station data to the Elite Dangerous Data Network";
/* companion.py: Generic "something went wrong with Frontier Auth" error; In files: companion.py:265; */
"Error: Invalid Credentials" = "Error: Invalid Credentials";
/* eddn.py: Enable EDDN support for system and other scan data checkbox label; In files: eddn.py:673; load.py:682; eddn.py:1133; */
"Send system and scan data to the Elite Dangerous Data Network" = "Send system and scan data to the Elite Dangerous Data Network";
/* companion.py: Frontier CAPI authorisation not for currently game-active commander; In files: companion.py:290; */
"Error: Wrong Cmdr" = "Error: Wrong Cmdr";
/* eddn.py: EDDN delay sending until docked option is on, this message notes that a send was skipped due to this; In files: eddn.py:683; load.py:692; eddn.py:1144; */
"Delay sending until docked" = "Delay sending until docked";
/* companion.py: Generic error prefix - following text is from Frontier auth service; In files: companion.py:416; companion.py:501; */
"Error" = "Error";
/* edsm.py: Settings>EDSM - Label on checkbox for 'send data'; In files: edsm.py:197; load.py:197; edsm.py:237; */
"Send flight log and Cmdr status to EDSM" = "Send flight log and Cmdr status to EDSM";
/* companion.py: Frontier auth, no 'usr' section in returned data; companion.py: Frontier auth, no 'customer_id' in 'usr' section in returned data; In files: companion.py:459; companion.py:464; */
"Error: Couldn't check token customer_id" = "Error: Couldn't check token customer_id";
/* edsm.py: Settings>EDSM - Label on header/URL to EDSM API key page; In files: edsm.py:206; load.py:206; edsm.py:247; */
"Elite Dangerous Star Map credentials" = "Elite Dangerous Star Map credentials";
/* companion.py: Frontier auth customer_id doesn't match game session FID; In files: companion.py:470; */
"Error: customer_id doesn't match!" = "Error: customer_id doesn't match!";
/* edsm.py: Game Commander name label in EDSM settings; theme.py: Label for commander name in main window; EDMarketConnector.py: Label for commander name in main window; stats.py: Cmdr stats; In files: edsm.py:216; load.py:216; edsm.py:258; theme.py:227; EDMarketConnector.py:822; stats.py:59; */
"Cmdr" = "Cmdr";
/* companion.py: Failed to get Access Token from Frontier Auth service; In files: companion.py:492; */
"Error: unable to get token" = "Error: unable to get token";
/* edsm.py: EDSM Commander name label in EDSM settings; In files: edsm.py:223; load.py:223; edsm.py:266; */
"Commander Name" = "Commander Name";
/* companion.py: Frontier CAPI returned 418, meaning down for maintenance; In files: companion.py:799; */
"Frontier CAPI down for maintenance" = "Frontier CAPI down for maintenance";
/* edsm.py: EDSM API key label; inara.py: Inara API key label; In files: edsm.py:230; inara.py:233; load.py:230; load.py:233; edsm.py:274; inara.py:243; */
"API Key" = "API Key";
/* companion.py: Frontier CAPI data retrieval failed; In files: companion.py:811; */
"Frontier CAPI query failure" = "Frontier CAPI query failure";
/* edsm.py: We have no data on the current commander; prefs.py: No hotkey/shortcut set; stats.py: No rank; In files: edsm.py:256; load.py:256; edsm.py:301; prefs.py:520; prefs.py:1174; prefs.py:1207; stats.py:156; stats.py:175; stats.py:194; stats.py:211; */
"None" = "None";
/* edsm.py: EDSM Plugin - Error message from EDSM API; In files: edsm.py:622; edsm.py:724; load.py:627; load.py:729; edsm.py:747; edsm.py:875; */
"Error: EDSM {MSG}" = "Error: EDSM {MSG}";
/* edsm.py: EDSM Plugin - Error connecting to EDSM API; In files: edsm.py:658; edsm.py:720; load.py:663; load.py:725; edsm.py:784; edsm.py:870; */
"Error: Can't connect to EDSM" = "Error: Can't connect to EDSM";
/* inara.py: Checkbox to enable INARA API Usage; In files: inara.py:215; load.py:215; inara.py:222; */
"Send flight log and Cmdr status to Inara" = "Send flight log and Cmdr status to Inara";
/* inara.py: Text for INARA API keys link ( goes to https://inara.cz/settings-api ); In files: inara.py:225; load.py:225; inara.py:234; */
"Inara credentials" = "Inara credentials";
/* inara.py: INARA API returned some kind of error (error message will be contained in {MSG}); In files: inara.py:1316; inara.py:1328; load.py:1319; load.py:1331; inara.py:1587; inara.py:1600; */
"Error: Inara {MSG}" = "Error: Inara {MSG}";
/* coriolis.py: 'Auto' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - auto; In files: load.py:51; load.py:51; load.py:88; load.py:104; load.py:110; coriolis.py:52; coriolis.py:55; coriolis.py:101; coriolis.py:117; coriolis.py:123; */
"Auto" = "Auto";
/* coriolis.py: 'Normal' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - normal; In files: load.py:51; load.py:88; load.py:102; coriolis.py:53; coriolis.py:99; coriolis.py:115; */
"Normal" = "Normal";
/* coriolis.py: 'Beta' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - beta; In files: load.py:51; load.py:88; load.py:103; coriolis.py:54; coriolis.py:100; coriolis.py:116; */
"Beta" = "Beta";
/* coriolis.py: Settings>Coriolis: Help/hint for changing coriolis URLs; In files: load.py:64:66; coriolis.py:69:71; */
"Set the URL to use with coriolis.io ship loadouts. Note that this MUST end with '/import?data='" = "Set the URL to use with coriolis.io ship loadouts. Note that this MUST end with '/import?data='";
/* coriolis.py: Settings>Coriolis: Label for 'NOT alpha/beta game version' URL; In files: load.py:69; coriolis.py:75; */
"Normal URL" = "Normal URL";
/* coriolis.py: Generic 'Reset' button label; In files: load.py:71; load.py:78; coriolis.py:78; coriolis.py:87; */
"Reset" = "Reset";
/* coriolis.py: Settings>Coriolis: Label for 'alpha/beta game version' URL; In files: load.py:76; coriolis.py:84; */
"Beta URL" = "Beta URL";
/* In files: load.py:83; */
"Override Beta/Normal selection" = "Override Beta/Normal selection";
/* coriolis.py: Settings>Coriolis - invalid override mode found; In files: load.py:120; coriolis.py:134; */
"Invalid Coriolis override mode!" = "Invalid Coriolis override mode!";
/* eddb.py: Journal Processing disabled due to an active killswitch; In files: load.py:99; eddb.py:100; */
"EDDB Journal processing disabled. See Log." = "EDDB Journal processing disabled. See Log.";
/* eddn.py: Killswitch disabled EDDN; In files: load.py:756; eddn.py:1233; */
"EDDN journal handler disabled. See Log." = "EDDN journal handler disabled. See Log.";
/* edsm.py: EDSM plugin - Journal handling disabled by killswitch; In files: load.py:346; edsm.py:402; */
"EDSM Handler disabled. See Log." = "EDSM Handler disabled. See Log.";
/* inara.py: INARA support disabled via killswitch; In files: load.py:331; inara.py:341; */
"Inara disabled. See Log." = "Inara disabled. See Log.";
/* EDMarketConnector.py: No data was returned for the commander from the Frontier CAPI; In files: test_ast_stuff.py:2; EDMarketConnector.py:1047; */
"CAPI: No commander data returned" = "CAPI: No commander data returned";
/* coriolis.py: Settings>Coriolis: Label for selection of using Normal, Beta or 'auto' Coriolis URL; In files: coriolis.py:94; */
"Override Beta/Normal Selection" = "Override Beta/Normal Selection";
/* eddn.py: EDDN has banned this version of our client; In files: eddn.py:334; */
"EDDN Error: EDMC is too old for EDDN. Please update." = "EDDN Error: EDMC is too old for EDDN. Please update.";
/* eddn.py: EDDN returned an error that indicates something about what we sent it was wrong; In files: eddn.py:340; */
"EDDN Error: Validation Failed (EDMC Too Old?). See Log" = "EDDN Error: Validation Failed (EDMC Too Old?). See Log";
/* eddn.py: EDDN returned some sort of HTTP error, one we didn't expect. {STATUS} contains a number; In files: eddn.py:345; */
"EDDN Error: Returned {STATUS} status code" = "EDDN Error: Returned {STATUS} status code";
/* eddn.py: No 'Route' found in NavRoute.json file; In files: eddn.py:971; */
"No 'Route' array in NavRoute.json contents" = "No 'Route' array in NavRoute.json contents";
/* journal_lock.py: Title text on popup when Journal directory already locked; In files: journal_lock.py:206; */
"Journal directory already locked" = "Journal directory already locked";
/* journal_lock.py: Text for when newly selected Journal directory is already locked; In files: journal_lock.py:223:224; */
"The new Journal Directory location is already locked.{CR}You can either attempt to resolve this and then Retry, or choose to Ignore this." = "The new Journal Directory location is already locked.{CR}You can either attempt to resolve this and then Retry, or choose to Ignore this.";
/* journal_lock.py: Generic 'Retry' button label; In files: journal_lock.py:228; */
"Retry" = "Retry";
/* journal_lock.py: Generic 'Ignore' button label; In files: journal_lock.py:232; */
"Ignore" = "Ignore";
/* ttkHyperlinkLabel.py: Label for 'Copy' as in 'Copy and Paste'; EDMarketConnector.py: Label for 'Copy' as in 'Copy and Paste'; In files: ttkHyperlinkLabel.py:42; EDMarketConnector.py:866; */
"Copy" = "Copy";
/* EDMarketConnector.py: Update button in main window; In files: EDMarketConnector.py:529; EDMarketConnector.py:828; EDMarketConnector.py:1520; */
"Update" = "Update";
@ -46,25 +133,22 @@
/* EDMarketConnector.py: ED Journal file location appears to be in error; In files: EDMarketConnector.py:815; */
"Error: Check E:D journal file location" = "Error: Check E:D journal file location";
/* EDMarketConnector.py: Label for commander name in main window; edsm.py: Game Commander name label in EDSM settings; stats.py: Cmdr stats; theme.py: Label for commander name in main window; In files: EDMarketConnector.py:822; edsm.py:257; stats.py:52; theme.py:227; */
"Cmdr" = "Cmdr";
/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: Multicrew role label in main window; In files: EDMarketConnector.py:824; EDMarketConnector.py:1279; */
"Role" = "Role";
/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: 'Ship' label in main UI; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:824; EDMarketConnector.py:1289; EDMarketConnector.py:1312; stats.py:367; */
/* EDMarketConnector.py: 'Ship' or multi-crew role label in main window, as applicable; EDMarketConnector.py: 'Ship' label in main UI; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:824; EDMarketConnector.py:1289; EDMarketConnector.py:1312; stats.py:409; */
"Ship" = "Ship";
/* EDMarketConnector.py: Label for 'Suit' line in main UI; In files: EDMarketConnector.py:825; */
"Suit" = "Suit";
/* EDMarketConnector.py: Label for 'System' line in main UI; prefs.py: Configuration - Label for selection of 'System' provider website; stats.py: Main window; In files: EDMarketConnector.py:826; prefs.py:607; stats.py:369; */
/* EDMarketConnector.py: Label for 'System' line in main UI; prefs.py: Configuration - Label for selection of 'System' provider website; stats.py: Main window; In files: EDMarketConnector.py:826; prefs.py:607; stats.py:411; */
"System" = "System";
/* EDMarketConnector.py: Label for 'Station' line in main UI; prefs.py: Configuration - Label for selection of 'Station' provider website; prefs.py: Appearance - Example 'Normal' text; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:827; prefs.py:625; prefs.py:762; stats.py:370; */
/* EDMarketConnector.py: Label for 'Station' line in main UI; prefs.py: Configuration - Label for selection of 'Station' provider website; prefs.py: Appearance - Example 'Normal' text; stats.py: Status dialog subtitle; In files: EDMarketConnector.py:827; prefs.py:625; prefs.py:762; stats.py:412; */
"Station" = "Station";
/* EDMarketConnector.py: 'File' menu title on OSX; EDMarketConnector.py: 'File' menu title; EDMarketConnector.py: 'File' menu; In files: EDMarketConnector.py:830; EDMarketConnector.py:845; EDMarketConnector.py:848; EDMarketConnector.py:2015; */
/* EDMarketConnector.py: 'File' menu title on OSX; EDMarketConnector.py: 'File' menu title; EDMarketConnector.py: 'File' menu; In files: EDMarketConnector.py:830; EDMarketConnector.py:845; EDMarketConnector.py:848; EDMarketConnector.py:2019; */
"File" = "File";
/* EDMarketConnector.py: 'Edit' menu title on OSX; EDMarketConnector.py: 'Edit' menu title; In files: EDMarketConnector.py:831; EDMarketConnector.py:846; EDMarketConnector.py:849; */
@ -88,7 +172,7 @@
/* EDMarketConnector.py: File > Save Raw Data...; In files: EDMarketConnector.py:840; EDMarketConnector.py:854; */
"Save Raw Data..." = "Save Raw Data...";
/* EDMarketConnector.py: File > Status; stats.py: Status dialog title; In files: EDMarketConnector.py:841; EDMarketConnector.py:853; stats.py:364; */
/* EDMarketConnector.py: File > Status; stats.py: Status dialog title; In files: EDMarketConnector.py:841; EDMarketConnector.py:853; stats.py:406; */
"Status" = "Status";
/* EDMarketConnector.py: Help > Privacy Policy; In files: EDMarketConnector.py:842; EDMarketConnector.py:860; */
@ -97,7 +181,7 @@
/* EDMarketConnector.py: Help > Release Notes; In files: EDMarketConnector.py:843; EDMarketConnector.py:861; EDMarketConnector.py:1600; */
"Release Notes" = "Release Notes";
/* EDMarketConnector.py: File > Settings; prefs.py: File > Settings (macOS); In files: EDMarketConnector.py:855; EDMarketConnector.py:2016; prefs.py:255; */
/* EDMarketConnector.py: File > Settings; prefs.py: File > Settings (macOS); In files: EDMarketConnector.py:855; EDMarketConnector.py:2020; prefs.py:255; */
"Settings" = "Settings";
/* EDMarketConnector.py: File > Exit; In files: EDMarketConnector.py:856; */
@ -106,9 +190,6 @@
/* EDMarketConnector.py: Help > Documentation; In files: EDMarketConnector.py:859; */
"Documentation" = "Documentation";
/* EDMarketConnector.py: Label for 'Copy' as in 'Copy and Paste'; ttkHyperlinkLabel.py: Label for 'Copy' as in 'Copy and Paste'; In files: EDMarketConnector.py:866; ttkHyperlinkLabel.py:42; */
"Copy" = "Copy";
/* EDMarketConnector.py: Status - Attempting to get a Frontier Auth Access Token; In files: EDMarketConnector.py:872; */
"Logging in..." = "Logging in...";
@ -142,16 +223,13 @@
/* EDMarketConnector.py: Status - Attempting to retrieve data from Frontier CAPI; In files: EDMarketConnector.py:1006; */
"Fetching data..." = "Fetching data...";
/* EDMarketConnector.py: No data was returned for the commander from the Frontier CAPI; In files: EDMarketConnector.py:1047; */
"CAPI: No commander data returned" = "CAPI: No commander data returned";
/* EDMarketConnector.py: We didn't have the commander name when we should have; stats.py: Unknown commander; In files: EDMarketConnector.py:1051; stats.py:298; */
/* EDMarketConnector.py: We didn't have the commander name when we should have; stats.py: Unknown commander; In files: EDMarketConnector.py:1051; stats.py:335; */
"Who are you?!" = "Who are you?!";
/* EDMarketConnector.py: We don't know where the commander is, when we should; stats.py: Unknown location; In files: EDMarketConnector.py:1057; stats.py:308; */
/* EDMarketConnector.py: We don't know where the commander is, when we should; stats.py: Unknown location; In files: EDMarketConnector.py:1057; stats.py:345; */
"Where are you?!" = "Where are you?!";
/* EDMarketConnector.py: We don't know what ship the commander is in, when we should; stats.py: Unknown ship; In files: EDMarketConnector.py:1064; stats.py:316; */
/* EDMarketConnector.py: We don't know what ship the commander is in, when we should; stats.py: Unknown ship; In files: EDMarketConnector.py:1064; stats.py:353; */
"What are you flying?!" = "What are you flying?!";
/* EDMarketConnector.py: Frontier CAPI server error when fetching data; In files: EDMarketConnector.py:1176; */
@ -181,126 +259,51 @@
/* EDMarketConnector.py: The application is shutting down; In files: EDMarketConnector.py:1693; */
"Shutting down..." = "Shutting down...";
/* EDMarketConnector.py: Popup-text about 'active' plugins without Python 3.x support; In files: EDMarketConnector.py:2004:2010; */
/* EDMarketConnector.py: Popup-text about 'active' plugins without Python 3.x support; In files: EDMarketConnector.py:2008:2014; */
"One or more of your enabled plugins do not yet have support for Python 3.x. Please see the list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an updated version available, else alert the developer that they need to update the code for Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on the end of the name." = "One or more of your enabled plugins do not yet have support for Python 3.x. Please see the list on the '{PLUGINS}' tab of '{FILE}' > '{SETTINGS}'. You should check if there is an updated version available, else alert the developer that they need to update the code for Python 3.x.\r\n\r\nYou can disable a plugin by renaming its folder to have '{DISABLED}' on the end of the name.";
/* EDMarketConnector.py: Settings > Plugins tab; prefs.py: Label on Settings > Plugins tab; In files: EDMarketConnector.py:2014; prefs.py:978; */
/* EDMarketConnector.py: Settings > Plugins tab; prefs.py: Label on Settings > Plugins tab; In files: EDMarketConnector.py:2018; prefs.py:978; */
"Plugins" = "Plugins";
/* EDMarketConnector.py: Popup window title for list of 'enabled' plugins that don't work with Python 3.x; In files: EDMarketConnector.py:2025; */
/* EDMarketConnector.py: Popup window title for list of 'enabled' plugins that don't work with Python 3.x; In files: EDMarketConnector.py:2029; */
"EDMC: Plugins Without Python 3.x Support" = "EDMC: Plugins Without Python 3.x Support";
/* journal_lock.py: Title text on popup when Journal directory already locked; In files: journal_lock.py:206; */
"Journal directory already locked" = "Journal directory already locked";
/* companion.py: Frontier CAPI didn't respond; In files: companion.py:218; */
"Error: Frontier CAPI didn't respond" = "Error: Frontier CAPI didn't respond";
/* journal_lock.py: Text for when newly selected Journal directory is already locked; In files: journal_lock.py:223:224; */
"The new Journal Directory location is already locked.{CR}You can either attempt to resolve this and then Retry, or choose to Ignore this." = "The new Journal Directory location is already locked.{CR}You can either attempt to resolve this and then Retry, or choose to Ignore this.";
/* companion.py: Frontier CAPI data doesn't agree with latest Journal game location; In files: companion.py:237; */
"Error: Frontier server is lagging" = "Error: Frontier server is lagging";
/* journal_lock.py: Generic 'Retry' button label; In files: journal_lock.py:228; */
"Retry" = "Retry";
/* companion.py: Commander is docked at an EDO settlement, got out and back in, we forgot the station; In files: companion.py:253; */
"Docked but unknown station: EDO Settlement?" = "Docked but unknown station: EDO Settlement?";
/* journal_lock.py: Generic 'Ignore' button label; In files: journal_lock.py:232; */
"Ignore" = "Ignore";
/* companion.py: Generic "something went wrong with Frontier Auth" error; In files: companion.py:263; */
"Error: Invalid Credentials" = "Error: Invalid Credentials";
/* companion.py: Frontier CAPI authorisation not for currently game-active commander; In files: companion.py:288; */
"Error: Wrong Cmdr" = "Error: Wrong Cmdr";
/* companion.py: Generic error prefix - following text is from Frontier auth service; In files: companion.py:414; companion.py:499; */
"Error" = "Error";
/* companion.py: Frontier auth, no 'usr' section in returned data; companion.py: Frontier auth, no 'customer_id' in 'usr' section in returned data; In files: companion.py:457; companion.py:462; */
"Error: Couldn't check token customer_id" = "Error: Couldn't check token customer_id";
/* companion.py: Frontier auth customer_id doesn't match game session FID; In files: companion.py:468; */
"Error: customer_id doesn't match!" = "Error: customer_id doesn't match!";
/* companion.py: Failed to get Access Token from Frontier Auth service; In files: companion.py:490; */
"Error: unable to get token" = "Error: unable to get token";
/* companion.py: Frontier CAPI returned 418, meaning down for maintenance; In files: companion.py:797; */
"Frontier CAPI down for maintenance" = "Frontier CAPI down for maintenance";
/* companion.py: Frontier CAPI data retrieval failed; In files: companion.py:809; */
"Frontier CAPI query failure" = "Frontier CAPI query failure";
/* l10n.py: The system default language choice in Settings > Appearance; prefs.py: Settings > Configuration - Label on 'reset journal files location to default' button; prefs.py: The system default language choice in Settings > Appearance; prefs.py: Label for 'Default' theme radio button; In files: l10n.py:194; prefs.py:468; prefs.py:702; prefs.py:735; */
"Default" = "Default";
/* coriolis.py: 'Auto' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - auto; In files: coriolis.py:52; coriolis.py:55; coriolis.py:101; coriolis.py:117; coriolis.py:123; */
"Auto" = "Auto";
/* coriolis.py: 'Normal' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - normal; In files: coriolis.py:53; coriolis.py:99; coriolis.py:115; */
"Normal" = "Normal";
/* coriolis.py: 'Beta' label for Coriolis site override selection; coriolis.py: Coriolis normal/beta selection - beta; In files: coriolis.py:54; coriolis.py:100; coriolis.py:116; */
"Beta" = "Beta";
/* coriolis.py: Settings>Coriolis: Help/hint for changing coriolis URLs; In files: coriolis.py:69:71; */
"Set the URL to use with coriolis.io ship loadouts. Note that this MUST end with '/import?data='" = "Set the URL to use with coriolis.io ship loadouts. Note that this MUST end with '/import?data='";
/* coriolis.py: Settings>Coriolis: Label for 'NOT alpha/beta game version' URL; In files: coriolis.py:75; */
"Normal URL" = "Normal URL";
/* coriolis.py: Generic 'Reset' button label; In files: coriolis.py:78; coriolis.py:87; */
"Reset" = "Reset";
/* coriolis.py: Settings>Coriolis: Label for 'alpha/beta game version' URL; In files: coriolis.py:84; */
"Beta URL" = "Beta URL";
/* coriolis.py: Settings>Coriolis: Label for selection of using Normal, Beta or 'auto' Coriolis URL; In files: coriolis.py:94; */
"Override Beta/Normal Selection" = "Override Beta/Normal Selection";
/* coriolis.py: Settings>Coriolis - invalid override mode found; In files: coriolis.py:134; */
"Invalid Coriolis override mode!" = "Invalid Coriolis override mode!";
/* eddb.py: Journal Processing disabled due to an active killswitch; In files: eddb.py:100; */
"EDDB Journal processing disabled. See Log." = "EDDB Journal processing disabled. See Log.";
/* eddn.py: Status text shown while attempting to send data; In files: eddn.py:251; eddn.py:698; eddn.py:1438; */
"Sending data to EDDN..." = "Sending data to EDDN...";
/* eddn.py: Error while trying to send data to EDDN; In files: eddn.py:315; eddn.py:1374; eddn.py:1409; eddn.py:1450; */
"Error: Can't connect to EDDN" = "Error: Can't connect to EDDN";
/* eddn.py: EDDN has banned this version of our client; In files: eddn.py:333; */
"EDDN Error: EDMC is too old for EDDN. Please update." = "EDDN Error: EDMC is too old for EDDN. Please update.";
/* eddn.py: EDDN returned an error that indicates something about what we sent it was wrong; In files: eddn.py:339; */
"EDDN Error: Validation Failed (EDMC Too Old?). See Log" = "EDDN Error: Validation Failed (EDMC Too Old?). See Log";
/* eddn.py: EDDN returned some sort of HTTP error, one we didn't expect. {STATUS} contains a number; In files: eddn.py:344; */
"EDDN Error: Returned {STATUS} status code" = "EDDN Error: Returned {STATUS} status code";
/* eddn.py: No 'Route' found in NavRoute.json file; In files: eddn.py:970; */
"No 'Route' array in NavRoute.json contents" = "No 'Route' array in NavRoute.json contents";
/* eddn.py: Enable EDDN support for station data checkbox label; In files: eddn.py:1121; */
"Send station data to the Elite Dangerous Data Network" = "Send station data to the Elite Dangerous Data Network";
/* eddn.py: Enable EDDN support for system and other scan data checkbox label; In files: eddn.py:1132; */
"Send system and scan data to the Elite Dangerous Data Network" = "Send system and scan data to the Elite Dangerous Data Network";
/* eddn.py: EDDN delay sending until docked option is on, this message notes that a send was skipped due to this; In files: eddn.py:1143; */
"Delay sending until docked" = "Delay sending until docked";
/* eddn.py: Killswitch disabled EDDN; In files: eddn.py:1232; */
"EDDN journal handler disabled. See Log." = "EDDN journal handler disabled. See Log.";
/* edsm.py: Settings>EDSM - Label on checkbox for 'send data'; In files: edsm.py:236; */
"Send flight log and Cmdr status to EDSM" = "Send flight log and Cmdr status to EDSM";
/* edsm.py: Settings>EDSM - Label on header/URL to EDSM API key page; In files: edsm.py:246; */
"Elite Dangerous Star Map credentials" = "Elite Dangerous Star Map credentials";
/* edsm.py: EDSM Commander name label in EDSM settings; In files: edsm.py:265; */
"Commander Name" = "Commander Name";
/* edsm.py: EDSM API key label; inara.py: Inara API key label; In files: edsm.py:273; inara.py:243; */
"API Key" = "API Key";
/* edsm.py: We have no data on the current commander; prefs.py: No hotkey/shortcut set; stats.py: No rank; In files: edsm.py:300; prefs.py:520; prefs.py:1174; prefs.py:1207; stats.py:119; stats.py:138; stats.py:157; stats.py:174; */
"None" = "None";
/* edsm.py: EDSM plugin - Journal handling disabled by killswitch; In files: edsm.py:401; */
"EDSM Handler disabled. See Log." = "EDSM Handler disabled. See Log.";
/* edsm.py: EDSM Plugin - Error message from EDSM API; In files: edsm.py:746; edsm.py:874; */
"Error: EDSM {MSG}" = "Error: EDSM {MSG}";
/* edsm.py: EDSM Plugin - Error connecting to EDSM API; In files: edsm.py:783; edsm.py:869; */
"Error: Can't connect to EDSM" = "Error: Can't connect to EDSM";
/* inara.py: Checkbox to enable INARA API Usage; In files: inara.py:222; */
"Send flight log and Cmdr status to Inara" = "Send flight log and Cmdr status to Inara";
/* inara.py: Text for INARA API keys link ( goes to https://inara.cz/settings-api ); In files: inara.py:234; */
"Inara credentials" = "Inara credentials";
/* inara.py: INARA support disabled via killswitch; In files: inara.py:341; */
"Inara disabled. See Log." = "Inara disabled. See Log.";
/* inara.py: INARA API returned some kind of error (error message will be contained in {MSG}); In files: inara.py:1580; inara.py:1593; */
"Error: Inara {MSG}" = "Error: Inara {MSG}";
/* prefs.py: File > Preferences menu entry for macOS; In files: prefs.py:251; */
"Preferences" = "Preferences";
@ -442,236 +445,303 @@
/* prefs.py: Lable on list of user-disabled plugins; In files: prefs.py:969; */
"Disabled Plugins" = "Disabled Plugins";
/* stats.py: Cmdr stats; In files: stats.py:53; */
/* stats.py: Cmdr stats; In files: stats.py:60; */
"Balance" = "Balance";
/* stats.py: Cmdr stats; In files: stats.py:54; */
/* stats.py: Cmdr stats; In files: stats.py:61; */
"Loan" = "Loan";
/* stats.py: Ranking; In files: stats.py:59; */
"Combat" = "Combat";
/* stats.py: Ranking; In files: stats.py:60; */
"Trade" = "Trade";
/* stats.py: Ranking; In files: stats.py:61; */
"Explorer" = "Explorer";
/* stats.py: Ranking; In files: stats.py:62; */
"CQC" = "CQC";
/* stats.py: Ranking; In files: stats.py:63; */
"Federation" = "Federation";
/* stats.py: Ranking; In files: stats.py:64; */
"Empire" = "Empire";
/* stats.py: Ranking; In files: stats.py:65; */
"Powerplay" = "Powerplay";
/* stats.py: Combat rank; In files: stats.py:73; */
"Harmless" = "Harmless";
/* stats.py: Combat rank; In files: stats.py:74; */
"Mostly Harmless" = "Mostly Harmless";
/* stats.py: Combat rank; In files: stats.py:75; */
"Novice" = "Novice";
/* stats.py: Combat rank; In files: stats.py:76; */
"Competent" = "Competent";
/* stats.py: Combat rank; In files: stats.py:77; */
"Expert" = "Expert";
/* stats.py: Combat rank; stats.py: Empire rank; In files: stats.py:78; stats.py:141; */
"Master" = "Master";
/* stats.py: Combat rank; In files: stats.py:79; */
"Dangerous" = "Dangerous";
/* stats.py: Combat rank; In files: stats.py:80; */
"Deadly" = "Deadly";
/* stats.py: Top rank; In files: stats.py:81; stats.py:92; stats.py:103; stats.py:114; */
/* stats.py: Top rank; In files: stats.py:65; */
"Elite" = "Elite";
/* stats.py: Trade rank; In files: stats.py:84; */
/* stats.py: Top rank +1; In files: stats.py:66; */
"Elite I" = "Elite I";
/* stats.py: Top rank +2; In files: stats.py:67; */
"Elite II" = "Elite II";
/* stats.py: Top rank +3; In files: stats.py:68; */
"Elite III" = "Elite III";
/* stats.py: Top rank +4; In files: stats.py:69; */
"Elite IV" = "Elite IV";
/* stats.py: Top rank +5; In files: stats.py:70; */
"Elite V" = "Elite V";
/* stats.py: Ranking; In files: stats.py:76; */
"Combat" = "Combat";
/* stats.py: Ranking; In files: stats.py:77; */
"Trade" = "Trade";
/* stats.py: Ranking; In files: stats.py:78; */
"Explorer" = "Explorer";
/* stats.py: Ranking; In files: stats.py:79; */
"Mercenary" = "Mercenary";
/* stats.py: Ranking; In files: stats.py:80; */
"Exobiologist" = "Exobiologist";
/* stats.py: Ranking; In files: stats.py:81; */
"CQC" = "CQC";
/* stats.py: Ranking; In files: stats.py:82; */
"Federation" = "Federation";
/* stats.py: Ranking; In files: stats.py:83; */
"Empire" = "Empire";
/* stats.py: Ranking; In files: stats.py:84; */
"Powerplay" = "Powerplay";
/* stats.py: Combat rank; In files: stats.py:93; */
"Harmless" = "Harmless";
/* stats.py: Combat rank; In files: stats.py:94; */
"Mostly Harmless" = "Mostly Harmless";
/* stats.py: Combat rank; In files: stats.py:95; */
"Novice" = "Novice";
/* stats.py: Combat rank; In files: stats.py:96; */
"Competent" = "Competent";
/* stats.py: Combat rank; In files: stats.py:97; */
"Expert" = "Expert";
/* stats.py: Combat rank; stats.py: Empire rank; In files: stats.py:98; stats.py:178; */
"Master" = "Master";
/* stats.py: Combat rank; In files: stats.py:99; */
"Dangerous" = "Dangerous";
/* stats.py: Combat rank; In files: stats.py:100; */
"Deadly" = "Deadly";
/* stats.py: Trade rank; In files: stats.py:103; */
"Penniless" = "Penniless";
/* stats.py: Trade rank; In files: stats.py:85; */
/* stats.py: Trade rank; In files: stats.py:104; */
"Mostly Penniless" = "Mostly Penniless";
/* stats.py: Trade rank; In files: stats.py:86; */
/* stats.py: Trade rank; In files: stats.py:105; */
"Peddler" = "Peddler";
/* stats.py: Trade rank; In files: stats.py:87; */
/* stats.py: Trade rank; In files: stats.py:106; */
"Dealer" = "Dealer";
/* stats.py: Trade rank; In files: stats.py:88; */
/* stats.py: Trade rank; In files: stats.py:107; */
"Merchant" = "Merchant";
/* stats.py: Trade rank; In files: stats.py:89; */
/* stats.py: Trade rank; In files: stats.py:108; */
"Broker" = "Broker";
/* stats.py: Trade rank; In files: stats.py:90; */
/* stats.py: Trade rank; In files: stats.py:109; */
"Entrepreneur" = "Entrepreneur";
/* stats.py: Trade rank; In files: stats.py:91; */
/* stats.py: Trade rank; In files: stats.py:110; */
"Tycoon" = "Tycoon";
/* stats.py: Explorer rank; In files: stats.py:95; */
/* stats.py: Explorer rank; In files: stats.py:113; */
"Aimless" = "Aimless";
/* stats.py: Explorer rank; In files: stats.py:96; */
/* stats.py: Explorer rank; In files: stats.py:114; */
"Mostly Aimless" = "Mostly Aimless";
/* stats.py: Explorer rank; In files: stats.py:97; */
/* stats.py: Explorer rank; In files: stats.py:115; */
"Scout" = "Scout";
/* stats.py: Explorer rank; In files: stats.py:98; */
/* stats.py: Explorer rank; In files: stats.py:116; */
"Surveyor" = "Surveyor";
/* stats.py: Explorer rank; In files: stats.py:99; */
/* stats.py: Explorer rank; In files: stats.py:117; */
"Trailblazer" = "Trailblazer";
/* stats.py: Explorer rank; In files: stats.py:100; */
/* stats.py: Explorer rank; In files: stats.py:118; */
"Pathfinder" = "Pathfinder";
/* stats.py: Explorer rank; In files: stats.py:101; */
/* stats.py: Explorer rank; In files: stats.py:119; */
"Ranger" = "Ranger";
/* stats.py: Explorer rank; In files: stats.py:102; */
/* stats.py: Explorer rank; In files: stats.py:120; */
"Pioneer" = "Pioneer";
/* stats.py: CQC rank; In files: stats.py:106; */
/* stats.py: Mercenary rank; In files: stats.py:124; */
"Defenceless" = "Defenceless";
/* stats.py: Mercenary rank; In files: stats.py:125; */
"Mostly Defenceless" = "Mostly Defenceless";
/* stats.py: Mercenary rank; In files: stats.py:126; */
"Rookie" = "Rookie";
/* stats.py: Mercenary rank; In files: stats.py:127; */
"Soldier" = "Soldier";
/* stats.py: Mercenary rank; In files: stats.py:128; stats.py:130; */
"Gunslinger" = "Gunslinger";
/* stats.py: Mercenary rank; In files: stats.py:129; */
"Warrior" = "Warrior";
/* stats.py: Mercenary rank; In files: stats.py:131; */
"Deadeye" = "Deadeye";
/* stats.py: Exobiologist rank; In files: stats.py:134; */
"Directionless" = "Directionless";
/* stats.py: Exobiologist rank; In files: stats.py:135; */
"Mostly Directionless" = "Mostly Directionless";
/* stats.py: Exobiologist rank; In files: stats.py:136; */
"Compiler" = "Compiler";
/* stats.py: Exobiologist rank; In files: stats.py:137; */
"Collector" = "Collector";
/* stats.py: Exobiologist rank; In files: stats.py:138; */
"Cataloguer" = "Cataloguer";
/* stats.py: Exobiologist rank; In files: stats.py:139; */
"Taxonomist" = "Taxonomist";
/* stats.py: Exobiologist rank; In files: stats.py:140; */
"Ecologist" = "Ecologist";
/* stats.py: Exobiologist rank; In files: stats.py:141; */
"Geneticist" = "Geneticist";
/* stats.py: CQC rank; In files: stats.py:144; */
"Helpless" = "Helpless";
/* stats.py: CQC rank; In files: stats.py:107; */
/* stats.py: CQC rank; In files: stats.py:145; */
"Mostly Helpless" = "Mostly Helpless";
/* stats.py: CQC rank; In files: stats.py:108; */
/* stats.py: CQC rank; In files: stats.py:146; */
"Amateur" = "Amateur";
/* stats.py: CQC rank; In files: stats.py:109; */
/* stats.py: CQC rank; In files: stats.py:147; */
"Semi Professional" = "Semi Professional";
/* stats.py: CQC rank; In files: stats.py:110; */
/* stats.py: CQC rank; In files: stats.py:148; */
"Professional" = "Professional";
/* stats.py: CQC rank; In files: stats.py:111; */
/* stats.py: CQC rank; In files: stats.py:149; */
"Champion" = "Champion";
/* stats.py: CQC rank; In files: stats.py:112; */
/* stats.py: CQC rank; In files: stats.py:150; */
"Hero" = "Hero";
/* stats.py: CQC rank; In files: stats.py:113; */
/* stats.py: CQC rank; In files: stats.py:151; */
"Gladiator" = "Gladiator";
/* stats.py: Federation rank; In files: stats.py:120; */
/* stats.py: Federation rank; In files: stats.py:157; */
"Recruit" = "Recruit";
/* stats.py: Federation rank; In files: stats.py:121; */
/* stats.py: Federation rank; In files: stats.py:158; */
"Cadet" = "Cadet";
/* stats.py: Federation rank; In files: stats.py:122; */
/* stats.py: Federation rank; In files: stats.py:159; */
"Midshipman" = "Midshipman";
/* stats.py: Federation rank; In files: stats.py:123; */
/* stats.py: Federation rank; In files: stats.py:160; */
"Petty Officer" = "Petty Officer";
/* stats.py: Federation rank; In files: stats.py:124; */
/* stats.py: Federation rank; In files: stats.py:161; */
"Chief Petty Officer" = "Chief Petty Officer";
/* stats.py: Federation rank; In files: stats.py:125; */
/* stats.py: Federation rank; In files: stats.py:162; */
"Warrant Officer" = "Warrant Officer";
/* stats.py: Federation rank; In files: stats.py:126; */
/* stats.py: Federation rank; In files: stats.py:163; */
"Ensign" = "Ensign";
/* stats.py: Federation rank; In files: stats.py:127; */
/* stats.py: Federation rank; In files: stats.py:164; */
"Lieutenant" = "Lieutenant";
/* stats.py: Federation rank; In files: stats.py:128; */
/* stats.py: Federation rank; In files: stats.py:165; */
"Lieutenant Commander" = "Lieutenant Commander";
/* stats.py: Federation rank; In files: stats.py:129; */
/* stats.py: Federation rank; In files: stats.py:166; */
"Post Commander" = "Post Commander";
/* stats.py: Federation rank; In files: stats.py:130; */
/* stats.py: Federation rank; In files: stats.py:167; */
"Post Captain" = "Post Captain";
/* stats.py: Federation rank; In files: stats.py:131; */
/* stats.py: Federation rank; In files: stats.py:168; */
"Rear Admiral" = "Rear Admiral";
/* stats.py: Federation rank; In files: stats.py:132; */
/* stats.py: Federation rank; In files: stats.py:169; */
"Vice Admiral" = "Vice Admiral";
/* stats.py: Federation rank; In files: stats.py:133; */
/* stats.py: Federation rank; In files: stats.py:170; */
"Admiral" = "Admiral";
/* stats.py: Empire rank; In files: stats.py:139; */
/* stats.py: Empire rank; In files: stats.py:176; */
"Outsider" = "Outsider";
/* stats.py: Empire rank; In files: stats.py:140; */
/* stats.py: Empire rank; In files: stats.py:177; */
"Serf" = "Serf";
/* stats.py: Empire rank; In files: stats.py:142; */
/* stats.py: Empire rank; In files: stats.py:179; */
"Squire" = "Squire";
/* stats.py: Empire rank; In files: stats.py:143; */
/* stats.py: Empire rank; In files: stats.py:180; */
"Knight" = "Knight";
/* stats.py: Empire rank; In files: stats.py:144; */
/* stats.py: Empire rank; In files: stats.py:181; */
"Lord" = "Lord";
/* stats.py: Empire rank; In files: stats.py:145; */
/* stats.py: Empire rank; In files: stats.py:182; */
"Baron" = "Baron";
/* stats.py: Empire rank; In files: stats.py:146; */
/* stats.py: Empire rank; In files: stats.py:183; */
"Viscount" = "Viscount";
/* stats.py: Empire rank; In files: stats.py:147; */
/* stats.py: Empire rank; In files: stats.py:184; */
"Count" = "Count";
/* stats.py: Empire rank; In files: stats.py:148; */
/* stats.py: Empire rank; In files: stats.py:185; */
"Earl" = "Earl";
/* stats.py: Empire rank; In files: stats.py:149; */
/* stats.py: Empire rank; In files: stats.py:186; */
"Marquis" = "Marquis";
/* stats.py: Empire rank; In files: stats.py:150; */
/* stats.py: Empire rank; In files: stats.py:187; */
"Duke" = "Duke";
/* stats.py: Empire rank; In files: stats.py:151; */
/* stats.py: Empire rank; In files: stats.py:188; */
"Prince" = "Prince";
/* stats.py: Empire rank; In files: stats.py:152; */
/* stats.py: Empire rank; In files: stats.py:189; */
"King" = "King";
/* stats.py: Power rank; In files: stats.py:158; */
/* stats.py: Power rank; In files: stats.py:195; */
"Rating 1" = "Rating 1";
/* stats.py: Power rank; In files: stats.py:159; */
/* stats.py: Power rank; In files: stats.py:196; */
"Rating 2" = "Rating 2";
/* stats.py: Power rank; In files: stats.py:160; */
/* stats.py: Power rank; In files: stats.py:197; */
"Rating 3" = "Rating 3";
/* stats.py: Power rank; In files: stats.py:161; */
/* stats.py: Power rank; In files: stats.py:198; */
"Rating 4" = "Rating 4";
/* stats.py: Power rank; In files: stats.py:162; */
/* stats.py: Power rank; In files: stats.py:199; */
"Rating 5" = "Rating 5";
/* stats.py: Current commander unknown when trying to use 'File' > 'Status'; In files: stats.py:280; */
/* stats.py: Current commander unknown when trying to use 'File' > 'Status'; In files: stats.py:317; */
"Status: Don't yet know your Commander name" = "Status: Don't yet know your Commander name";
/* stats.py: No Frontier CAPI data yet when trying to use 'File' > 'Status'; In files: stats.py:288; */
/* stats.py: No Frontier CAPI data yet when trying to use 'File' > 'Status'; In files: stats.py:325; */
"Status: No CAPI data yet" = "Status: No CAPI data yet";
/* stats.py: Status dialog subtitle - CR value of ship; In files: stats.py:371; */
/* stats.py: Status dialog subtitle - CR value of ship; In files: stats.py:413; */
"Value" = "Value";
/* stats.py: Status dialog title; In files: stats.py:380; */
/* stats.py: Status dialog title; In files: stats.py:422; */
"Ships" = "Ships";

View File

@ -250,6 +250,9 @@
/* eddn.py: EDDN returned some sort of HTTP error, one we didn't expect. {STATUS} contains a number; In files: eddn.py:344; */
"EDDN Error: Returned {STATUS} status code" = "Błąd EDDN: Zwrócono kod {STATUS}";
/* eddn.py: No 'Route' found in NavRoute.json file; In files: eddn.py:970; */
"No 'Route' array in NavRoute.json contents" = "Brak tablicy \"Route\" wewnątrz NavRoute.json";
/* eddn.py: Enable EDDN support for station data checkbox label; In files: eddn.py:1121; */
"Send station data to the Elite Dangerous Data Network" = "Wyślij dane do Elite Dangerous Data Network";

View File

@ -1,9 +1,21 @@
#!/usr/bin/env python3
"""Collate lists of seen commodities, modules and ships from dumps of the Companion API output."""
"""
Collate lists of seen commodities, modules and ships from dumps of the Companion API output.
Note that currently this will only work with the output files created if you
run the main program from a working directory that has a `dump/` directory,
which causes a file to be written per CAPI query.
This script also utilise the file outfitting.csv. As it both reads it in *and*
writes out a new copy a local copy, in the root of the project structure, is
used for this purpose. If you want to utilise the FDevIDs/ version of the
file, copy it over the local one.
"""
import csv
import json
import os
import pathlib
import sys
from os.path import isfile
from traceback import print_exc
@ -13,14 +25,14 @@ import outfitting
from edmc_data import companion_category_map, ship_name_map
def __make_backup(file_name: str, suffix: str = '.bak') -> None:
def __make_backup(file_name: pathlib.Path, suffix: str = '.bak') -> None:
"""
Rename the given file to $file.bak, removing any existing $file.bak. Assumes $file exists on disk.
:param file_name: The name of the file to make a backup of
:param suffix: The suffix to use for backup files (default '.bak')
"""
backup_name = file_name + suffix
backup_name = file_name.parent / (file_name.name + suffix)
if isfile(backup_name):
os.unlink(backup_name)
@ -38,7 +50,7 @@ def addcommodities(data) -> None: # noqa: CCR001
if not data['lastStarport'].get('commodities'):
return
commodityfile = 'commodity.csv'
commodityfile = pathlib.Path('FDevIDs/commodity.csv')
commodities = {}
# slurp existing
@ -149,7 +161,7 @@ def addships(data) -> None: # noqa: CCR001
if not data['lastStarport'].get('ships'):
return
shipfile = 'shipyard.csv'
shipfile = pathlib.Path('shipyard.csv')
ships = {}
fields = ('id', 'symbol', 'name')
@ -209,6 +221,7 @@ if __name__ == "__main__":
with open(file_name) as f:
print(file_name)
data = json.load(f)
data = data['data']
if not data['commander'].get('docked'):
print('Not docked!')

View File

@ -22,7 +22,6 @@ import urllib.parse
import webbrowser
from builtins import object, range, str
from email.utils import parsedate
from os.path import join
from queue import Queue
from typing import TYPE_CHECKING, Any, Dict, List, Mapping, Optional, OrderedDict, TypeVar, Union
@ -30,7 +29,7 @@ import requests
import config as conf_module
import protocol
from config import appname, appversion, config
from config import config, user_agent
from edmc_data import companion_category_map as category_map
from EDMCLogging import get_main_logger
from monitor import monitor
@ -54,7 +53,6 @@ auth_timeout = 30 # timeout for initial auth
# Used by both class Auth and Session
FRONTIER_AUTH_SERVER = 'https://auth.frontierstore.net'
USER_AGENT = f'EDCD-{appname}-{appversion()}'
SERVER_LIVE = 'https://companion.orerve.net'
SERVER_BETA = 'https://pts-companion.orerve.net'
@ -305,7 +303,7 @@ class Auth(object):
def __init__(self, cmdr: str) -> None:
self.cmdr: str = cmdr
self.requests_session = requests.Session()
self.requests_session.headers['User-Agent'] = USER_AGENT
self.requests_session.headers['User-Agent'] = user_agent
self.verifier: Union[bytes, None] = None
self.state: Union[str, None] = None
@ -644,7 +642,7 @@ class Session(object):
logger.debug('Starting session')
self.requests_session = requests.Session()
self.requests_session.headers['Authorization'] = f'Bearer {access_token}'
self.requests_session.headers['User-Agent'] = USER_AGENT
self.requests_session.headers['User-Agent'] = user_agent
self.state = Session.STATE_OK
def login(self, cmdr: str = None, is_beta: Optional[bool] = None) -> bool:
@ -1066,7 +1064,7 @@ def fixup(data: CAPIData) -> CAPIData: # noqa: C901, CCR001 # Can't be usefully
if not commodity_map:
# Lazily populate
for f in ('commodity.csv', 'rare_commodity.csv'):
with open(join(config.respath_path, f), 'r') as csvfile:
with open(config.respath_path / 'FDevIDs' / f, 'r') as csvfile:
reader = csv.DictReader(csvfile)
for row in reader:

1112
config.py

File diff suppressed because it is too large Load Diff

473
config/__init__.py Normal file
View File

@ -0,0 +1,473 @@
"""
Code dealing with the configuration of the program.
Windows uses the Registry to store values in a flat manner.
Linux uses a file, but for commonality it's still a flat data structure.
macOS uses a 'defaults' object.
"""
__all__ = [
# defined in the order they appear in the file
'GITVERSION_FILE',
'appname',
'applongname',
'appcmdname',
'copyright',
'update_feed',
'update_interval',
'debug_senders',
'trace_on',
'capi_pretend_down',
'capi_debug_access_token',
'logger',
'git_shorthash_from_head',
'appversion',
'user_agent',
'appversion_nobuild',
'AbstractConfig',
'config'
]
import abc
import contextlib
import logging
import os
import pathlib
import re
import subprocess
import sys
import traceback
import warnings
from abc import abstractmethod
from typing import Any, Callable, List, Optional, Type, TypeVar, Union
import semantic_version
from constants import GITVERSION_FILE, applongname, appname
# Any of these may be imported by plugins
appcmdname = 'EDMC'
# appversion **MUST** follow Semantic Versioning rules:
# <https://semver.org/#semantic-versioning-specification-semver>
# Major.Minor.Patch(-prerelease)(+buildmetadata)
# NB: Do *not* import this, use the functions appversion() and appversion_nobuild()
_static_appversion = '5.3.0'
_cached_version: Optional[semantic_version.Version] = None
copyright = '© 2015-2019 Jonathan Harris, 2020-2022 EDCD'
update_feed = 'https://raw.githubusercontent.com/EDCD/EDMarketConnector/releases/edmarketconnector.xml'
update_interval = 8*60*60
# Providers marked to be in debug mode. Generally this is expected to switch to sending data to a log file
debug_senders: List[str] = []
# TRACE logging code that should actually be used. Means not spamming it
# *all* if only interested in some things.
trace_on: List[str] = []
capi_pretend_down: bool = False
capi_debug_access_token: Optional[str] = None
# This must be done here in order to avoid an import cycle with EDMCLogging.
# Other code should use EDMCLogging.get_main_logger
if os.getenv("EDMC_NO_UI"):
logger = logging.getLogger(appcmdname)
else:
logger = logging.getLogger(appname)
_T = TypeVar('_T')
###########################################################################
def git_shorthash_from_head() -> str:
"""
Determine short hash for current git HEAD.
Includes `.DIRTY` if any changes have been made from HEAD
:return: str - None if we couldn't determine the short hash.
"""
shorthash: str = None # type: ignore
try:
git_cmd = subprocess.Popen('git rev-parse --short HEAD'.split(),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT
)
out, err = git_cmd.communicate()
except Exception as e:
logger.info(f"Couldn't run git command for short hash: {e!r}")
else:
shorthash = out.decode().rstrip('\n')
if re.match(r'^[0-9a-f]{7,}$', shorthash) is None:
logger.error(f"'{shorthash}' doesn't look like a valid git short hash, forcing to None")
shorthash = None # type: ignore
if shorthash is not None:
with contextlib.suppress(Exception):
result = subprocess.run('git diff --stat HEAD'.split(), capture_output=True)
if len(result.stdout) > 0:
shorthash += '.DIRTY'
if len(result.stderr) > 0:
logger.warning(f'Data from git on stderr:\n{str(result.stderr)}')
return shorthash
def appversion() -> semantic_version.Version:
"""
Determine app version including git short hash if possible.
:return: The augmented app version.
"""
global _cached_version
if _cached_version is not None:
return _cached_version
if getattr(sys, 'frozen', False):
# Running frozen, so we should have a .gitversion file
# Yes, .parent because if frozen we're inside library.zip
with open(pathlib.Path(sys.path[0]).parent / GITVERSION_FILE, 'r', encoding='utf-8') as gitv:
shorthash = gitv.read()
else:
# Running from source
shorthash = git_shorthash_from_head()
if shorthash is None:
shorthash = 'UNKNOWN'
_cached_version = semantic_version.Version(f'{_static_appversion}+{shorthash}')
return _cached_version
user_agent = f'EDCD-{appname}-{appversion()}'
def appversion_nobuild() -> semantic_version.Version:
"""
Determine app version without *any* build meta data.
This will not only strip any added git short hash, but also any trailing
'+<string>' in _static_appversion.
:return: App version without any build meta data.
"""
return appversion().truncate('prerelease')
###########################################################################
class AbstractConfig(abc.ABC):
"""Abstract root class of all platform specific Config implementations."""
OUT_MKT_EDDN = 1
# OUT_MKT_BPC = 2 # No longer supported
OUT_MKT_TD = 4
OUT_MKT_CSV = 8
OUT_SHIP = 16
# OUT_SHIP_EDS = 16 # Replaced by OUT_SHIP
# OUT_SYS_FILE = 32 # No longer supported
# OUT_STAT = 64 # No longer available
# OUT_SHIP_CORIOLIS = 128 # Replaced by OUT_SHIP
OUT_STATION_ANY = OUT_MKT_EDDN | OUT_MKT_TD | OUT_MKT_CSV
# OUT_SYS_EDSM = 256 # Now a plugin
# OUT_SYS_AUTO = 512 # Now always automatic
OUT_MKT_MANUAL = 1024
OUT_SYS_EDDN = 2048
OUT_SYS_DELAY = 4096
app_dir_path: pathlib.Path
plugin_dir_path: pathlib.Path
internal_plugin_dir_path: pathlib.Path
respath_path: pathlib.Path
home_path: pathlib.Path
default_journal_dir_path: pathlib.Path
identifier: str
__in_shutdown = False # Is the application currently shutting down ?
__auth_force_localserver = False # Should we use localhost for auth callback ?
__auth_force_edmc_protocol = False # Should we force edmc:// protocol ?
__eddn_url = None # Non-default EDDN URL
__eddn_tracking_ui = False # Show EDDN tracking UI ?
def __init__(self) -> None:
self.home_path = pathlib.Path.home()
def set_shutdown(self):
"""Set flag denoting we're in the shutdown sequence."""
self.__in_shutdown = True
@property
def shutting_down(self) -> bool:
"""
Determine if we're in the shutdown sequence.
:return: bool - True if in shutdown sequence.
"""
return self.__in_shutdown
def set_auth_force_localserver(self):
"""Set flag to force use of localhost web server for Frontier Auth callback."""
self.__auth_force_localserver = True
@property
def auth_force_localserver(self) -> bool:
"""
Determine if use of localhost is forced for Frontier Auth callback.
:return: bool - True if we should use localhost web server.
"""
return self.__auth_force_localserver
def set_auth_force_edmc_protocol(self):
"""Set flag to force use of localhost web server for Frontier Auth callback."""
self.__auth_force_edmc_protocol = True
@property
def auth_force_edmc_protocol(self) -> bool:
"""
Determine if use of localhost is forced for Frontier Auth callback.
:return: bool - True if we should use localhost web server.
"""
return self.__auth_force_edmc_protocol
def set_eddn_url(self, eddn_url: str):
"""Set the specified eddn URL."""
self.__eddn_url = eddn_url
@property
def eddn_url(self) -> Optional[str]:
"""
Provide the custom EDDN URL.
:return: str - Custom EDDN URL to use.
"""
return self.__eddn_url
def set_eddn_tracking_ui(self):
"""Activate EDDN tracking UI."""
self.__eddn_tracking_ui = True
@property
def eddn_tracking_ui(self) -> bool:
"""
Determine if the EDDN tracking UI be shown.
:return: bool - Should tracking UI be active?
"""
return self.__eddn_tracking_ui
@property
def app_dir(self) -> str:
"""Return a string version of app_dir."""
return str(self.app_dir_path)
@property
def plugin_dir(self) -> str:
"""Return a string version of plugin_dir."""
return str(self.plugin_dir_path)
@property
def internal_plugin_dir(self) -> str:
"""Return a string version of internal_plugin_dir."""
return str(self.internal_plugin_dir_path)
@property
def respath(self) -> str:
"""Return a string version of respath."""
return str(self.respath_path)
@property
def home(self) -> str:
"""Return a string version of home."""
return str(self.home_path)
@property
def default_journal_dir(self) -> str:
"""Return a string version of default_journal_dir."""
return str(self.default_journal_dir_path)
@staticmethod
def _suppress_call(
func: Callable[..., _T], exceptions: Union[Type[BaseException], List[Type[BaseException]]] = Exception,
*args: Any, **kwargs: Any
) -> Optional[_T]:
if exceptions is None:
exceptions = [Exception]
if not isinstance(exceptions, list):
exceptions = [exceptions]
with contextlib.suppress(*exceptions): # type: ignore # it works fine, mypy
return func(*args, **kwargs)
return None
def get(self, key: str, default: Union[list, str, bool, int] = None) -> Union[list, str, bool, int]:
"""
Return the data for the requested key, or a default.
:param key: The key data is being requested for.
:param default: The default to return if the key does not exist, defaults to None.
:raises OSError: On Windows, if a Registry error occurs.
:return: The data or the default.
"""
warnings.warn(DeprecationWarning('get is Deprecated. use the specific getter for your type'))
logger.debug('Attempt to use Deprecated get() method\n' + ''.join(traceback.format_stack()))
if (l := self._suppress_call(self.get_list, ValueError, key, default=None)) is not None:
return l
elif (s := self._suppress_call(self.get_str, ValueError, key, default=None)) is not None:
return s
elif (b := self._suppress_call(self.get_bool, ValueError, key, default=None)) is not None:
return b
elif (i := self._suppress_call(self.get_int, ValueError, key, default=None)) is not None:
return i
return default # type: ignore
@abstractmethod
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
raise NotImplementedError
@abstractmethod
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
:param key: The key data is being requested for.
:param default: Default to return if the key does not exist, defaults to None.
:raises ValueError: If an internal error occurs getting or converting a value.
:raises OSError: On Windows, if a Registry error occurs.
:return: The requested data or the default.
"""
raise NotImplementedError
@abstractmethod
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
:param key: The key data is being requested for.
:param default: Default to return if the key does not exist, defaults to None
:raises ValueError: If an internal error occurs getting or converting a value
:raises OSError: On Windows, if a Registry error occurs.
:return: The requested data or the default
"""
raise NotImplementedError
def getint(self, key: str, *, default: int = 0) -> int:
"""
Getint is a Deprecated getter method.
See get_int for its replacement.
:raises OSError: On Windows, if a Registry error occurs.
"""
warnings.warn(DeprecationWarning('getint is Deprecated. Use get_int instead'))
logger.debug('Attempt to use Deprecated getint() method\n' + ''.join(traceback.format_stack()))
return self.get_int(key, default=default)
@abstractmethod
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
For legacy reasons, the default is 0 and not None.
:param key: The key data is being requested for.
:param default: Default to return if the key does not exist, defaults to 0.
:raises ValueError: If the internal representation of this key cannot be converted to an int.
:raises OSError: On Windows, if a Registry error occurs.
:return: The requested data or the default.
"""
raise NotImplementedError
@abstractmethod
def set(self, key: str, val: Union[int, str, List[str], bool]) -> None:
"""
Set the given key's data to the given value.
:param key: The key to set the value on.
:param val: The value to set the key's data to.
:raises ValueError: On an invalid type.
:raises OSError: On Windows, if a Registry error occurs.
"""
raise NotImplementedError
@abstractmethod
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
:param key: The key to delete.
:param suppress: bool - Whether to suppress any errors. Useful in case
code to migrate settings is blindly removing an old key.
:raises OSError: On Windows, if a registry error occurs.
"""
raise NotImplementedError
@abstractmethod
def save(self) -> None:
"""
Save the current configuration.
:raises OSError: On Windows, if a Registry error occurs.
"""
raise NotImplementedError
@abstractmethod
def close(self) -> None:
"""Close this config and release any associated resources."""
raise NotImplementedError
def get_password(self, account: str) -> None:
"""Legacy password retrieval."""
warnings.warn("password subsystem is no longer supported", DeprecationWarning)
def set_password(self, account: str, password: str) -> None:
"""Legacy password setting."""
warnings.warn("password subsystem is no longer supported", DeprecationWarning)
def delete_password(self, account: str) -> None:
"""Legacy password deletion."""
warnings.warn("password subsystem is no longer supported", DeprecationWarning)
def get_config(*args, **kwargs) -> AbstractConfig:
"""
Get the appropriate config class for the current platform.
:param args: Args to be passed through to implementation.
:param kwargs: Args to be passed through to implementation.
:return: Instance of the implementation.
"""
if sys.platform == "darwin":
from .darwin import MacConfig
return MacConfig(*args, **kwargs)
elif sys.platform == "win32":
from .windows import WinConfig
return WinConfig(*args, **kwargs)
elif sys.platform == "linux":
from .linux import LinuxConfig
return LinuxConfig(*args, **kwargs)
else:
raise ValueError(f'Unknown platform: {sys.platform=}')
config = get_config()

184
config/darwin.py Normal file
View File

@ -0,0 +1,184 @@
import pathlib
import sys
from typing import Any, Dict, List, Union
from Foundation import ( # type: ignore
NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains, NSUserDefaults,
NSUserDomainMask
)
from config import AbstractConfig, appname, logger
assert sys.platform == 'darwin'
class MacConfig(AbstractConfig):
"""MacConfig is the implementation of AbstractConfig for Darwin based OSes."""
def __init__(self) -> None:
super().__init__()
support_path = pathlib.Path(
NSSearchPathForDirectoriesInDomains(
NSApplicationSupportDirectory, NSUserDomainMask, True
)[0]
)
self.app_dir_path = support_path / appname
self.app_dir_path.mkdir(exist_ok=True)
self.plugin_dir_path = self.app_dir_path / 'plugins'
self.plugin_dir_path.mkdir(exist_ok=True)
# Bundle IDs identify a singled app though out a system
if getattr(sys, 'frozen', False):
exe_dir = pathlib.Path(sys.executable).parent
self.internal_plugin_dir_path = exe_dir.parent / 'Library' / 'plugins'
self.respath_path = exe_dir.parent / 'Resources'
self.identifier = NSBundle.mainBundle().bundleIdentifier()
else:
file_dir = pathlib.Path(__file__).parent.parent
self.internal_plugin_dir_path = file_dir / 'plugins'
self.respath_path = file_dir
self.identifier = f'uk.org.marginal.{appname.lower()}'
NSBundle.mainBundle().infoDictionary()['CFBundleIdentifier'] = self.identifier
self.default_journal_dir_path = support_path / 'Frontier Developments' / 'Elite Dangerous'
self._defaults: Any = NSUserDefaults.standardUserDefaults()
self._settings: Dict[str, Union[int, str, list]] = dict(
self._defaults.persistentDomainForName_(self.identifier) or {}
) # make writeable
if (out_dir := self.get_str('out_dir')) is None or not pathlib.Path(out_dir).exists():
self.set('outdir', NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, True)[0])
def __raw_get(self, key: str) -> Union[None, list, str, int]:
"""
Retrieve the raw data for the given key.
:param str: str - The key data is being requested for.
:return: The requested data.
"""
res = self._settings.get(key)
# On MacOS Catalina, with python.org python 3.9.2 any 'list'
# has type __NSCFArray so a simple `isinstance(res, list)` is
# False. So, check it's not-None, and not the other types.
#
# If we can find where to import the definition of NSCFArray
# then we could possibly test against that.
if res is not None and not isinstance(res, str) and not isinstance(res, int):
return list(res)
return res
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_str`.
"""
res = self.__raw_get(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
if not isinstance(res, str):
raise ValueError(f'unexpected data returned from __raw_get: {type(res)=} {res}')
return res
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
res = self.__raw_get(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, list):
raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}')
return res
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
Implements :meth:`AbstractConfig.get_int`.
"""
res = self.__raw_get(key)
if res is None:
return default
elif not isinstance(res, (str, int)):
raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}')
try:
return int(res)
except ValueError as e:
logger.error(f'__raw_get returned {res!r} which cannot be parsed to an int: {e}')
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_bool`.
"""
res = self.__raw_get(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, bool):
raise ValueError(f'__raw_get returned unexpected type {type(res)=} {res!r}')
return res
def set(self, key: str, val: Union[int, str, List[str], bool]) -> None:
"""
Set the given key's data to the given value.
Implements :meth:`AbstractConfig.set`.
"""
if self._settings is None:
raise ValueError('attempt to use a closed _settings')
if not isinstance(val, (bool, str, int, list)):
raise ValueError(f'Unexpected type for value {type(val)=}')
self._settings[key] = val
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
Implements :meth:`AbstractConfig.delete`.
"""
try:
del self._settings[key]
except Exception:
if suppress:
pass
def save(self) -> None:
"""
Save the current configuration.
Implements :meth:`AbstractConfig.save`.
"""
self._defaults.setPersistentDomain_forName_(self._settings, self.identifier)
self._defaults.synchronize()
def close(self) -> None:
"""
Close this config and release any associated resources.
Implements :meth:`AbstractConfig.close`.
"""
self.save()
self._defaults = None

245
config/linux.py Normal file
View File

@ -0,0 +1,245 @@
"""Linux config implementation."""
import os
import pathlib
import sys
from configparser import ConfigParser
from typing import TYPE_CHECKING, List, Optional, Union
from config import AbstractConfig, appname, logger
assert sys.platform == 'linux'
class LinuxConfig(AbstractConfig):
"""Linux implementation of AbstractConfig."""
SECTION = 'config'
# TODO: I dislike this, would rather use a sane config file format. But here we are.
__unescape_lut = {'\\': '\\', 'n': '\n', ';': ';', 'r': '\r', '#': '#'}
__escape_lut = {'\\': '\\', '\n': 'n', ';': ';', '\r': 'r'}
def __init__(self, filename: Optional[str] = None) -> None:
super().__init__()
# http://standards.freedesktop.org/basedir-spec/latest/ar01s03.html
xdg_data_home = pathlib.Path(os.getenv('XDG_DATA_HOME', default='~/.local/share')).expanduser()
self.app_dir_path = xdg_data_home / appname
self.app_dir_path.mkdir(exist_ok=True, parents=True)
self.plugin_dir_path = self.app_dir_path / 'plugins'
self.plugin_dir_path.mkdir(exist_ok=True)
self.respath_path = pathlib.Path(__file__).parent.parent
self.internal_plugin_dir_path = self.respath_path / 'plugins'
self.default_journal_dir_path = None # type: ignore
self.identifier = f'uk.org.marginal.{appname.lower()}' # TODO: Unused?
config_home = pathlib.Path(os.getenv('XDG_CONFIG_HOME', default='~/.config')).expanduser()
self.filename = config_home / appname / f'{appname}.ini'
if filename is not None:
self.filename = pathlib.Path(filename)
self.filename.parent.mkdir(exist_ok=True, parents=True)
self.config: Optional[ConfigParser] = ConfigParser(comment_prefixes=('#',), interpolation=None)
self.config.read(self.filename) # read() ignores files that dont exist
# Ensure that our section exists. This is here because configparser will happily create files for us, but it
# does not magically create sections
try:
self.config[self.SECTION].get("this_does_not_exist", fallback=None)
except KeyError:
logger.info("Config section not found. Backing up existing file (if any) and readding a section header")
if self.filename.exists():
(self.filename.parent / f'{appname}.ini.backup').write_bytes(self.filename.read_bytes())
self.config.add_section(self.SECTION)
if (outdir := self.get_str('outdir')) is None or not pathlib.Path(outdir).is_dir():
self.set('outdir', self.home)
def __escape(self, s: str) -> str:
"""
Escape a string using self.__escape_lut.
This does NOT support multi-character escapes.
:param s: str - String to be escaped.
:return: str - The escaped string.
"""
out = ""
for c in s:
if c not in self.__escape_lut:
out += c
continue
out += '\\' + self.__escape_lut[c]
return out
def __unescape(self, s: str) -> str:
"""
Unescape a string.
:param s: str - The string to unescape.
:return: str - The unescaped string.
"""
out: List[str] = []
i = 0
while i < len(s):
c = s[i]
if c != '\\':
out.append(c)
i += 1
continue
# We have a backslash, check what its escaping
if i == len(s)-1:
raise ValueError('Escaped string has unescaped trailer')
unescaped = self.__unescape_lut.get(s[i+1])
if unescaped is None:
raise ValueError(f'Unknown escape: \\ {s[i+1]}')
out.append(unescaped)
i += 2
return "".join(out)
def __raw_get(self, key: str) -> Optional[str]:
"""
Get a raw data value from the config file.
:param key: str - The key data is being requested for.
:return: str - The raw data, if found.
"""
if self.config is None:
raise ValueError('Attempt to use a closed config')
return self.config[self.SECTION].get(key)
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_str`.
"""
data = self.__raw_get(key)
if data is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
if '\n' in data:
raise ValueError('asked for string, got list')
return self.__unescape(data)
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
data = self.__raw_get(key)
if data is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
split = data.split('\n')
if split[-1] != ';':
raise ValueError('Encoded list does not have trailer sentinel')
return list(map(self.__unescape, split[:-1]))
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
Implements :meth:`AbstractConfig.get_int`.
"""
data = self.__raw_get(key)
if data is None:
return default
try:
return int(data)
except ValueError as e:
raise ValueError(f'requested {key=} as int cannot be converted to int') from e
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_bool`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
data = self.__raw_get(key)
if data is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
return bool(int(data))
def set(self, key: str, val: Union[int, str, List[str]]) -> None:
"""
Set the given key's data to the given value.
Implements :meth:`AbstractConfig.set`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
to_set: Optional[str] = None
if isinstance(val, bool):
to_set = str(int(val))
elif isinstance(val, str):
to_set = self.__escape(val)
elif isinstance(val, int):
to_set = str(val)
elif isinstance(val, list):
to_set = '\n'.join([self.__escape(s) for s in val] + [';'])
else:
raise ValueError(f'Unexpected type for value {type(val)=}')
self.config.set(self.SECTION, key, to_set)
self.save()
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
Implements :meth:`AbstractConfig.delete`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
self.config.remove_option(self.SECTION, key)
self.save()
def save(self) -> None:
"""
Save the current configuration.
Implements :meth:`AbstractConfig.save`.
"""
if self.config is None:
raise ValueError('attempt to use a closed config')
with open(self.filename, 'w', encoding='utf-8') as f:
self.config.write(f)
def close(self) -> None:
"""
Close this config and release any associated resources.
Implements :meth:`AbstractConfig.close`.
"""
self.save()
self.config = None

259
config/windows.py Normal file
View File

@ -0,0 +1,259 @@
"""Windows config implementation."""
# spell-checker: words folderid deps hkey edcd
import ctypes
import functools
import pathlib
import sys
import uuid
import winreg
from ctypes.wintypes import DWORD, HANDLE
from typing import List, Optional, Union
from config import AbstractConfig, applongname, appname, logger, update_interval
assert sys.platform == 'win32'
REG_RESERVED_ALWAYS_ZERO = 0
# This is the only way to do this from python without external deps (which do this anyway).
FOLDERID_Documents = uuid.UUID('{FDD39AD0-238F-46AF-ADB4-6C85480369C7}')
FOLDERID_LocalAppData = uuid.UUID('{F1B32785-6FBA-4FCF-9D55-7B8E7F157091}')
FOLDERID_Profile = uuid.UUID('{5E6C858F-0E22-4760-9AFE-EA3317B67173}')
FOLDERID_SavedGames = uuid.UUID('{4C5C32FF-BB9D-43b0-B5B4-2D72E54EAAA4}')
SHGetKnownFolderPath = ctypes.windll.shell32.SHGetKnownFolderPath
SHGetKnownFolderPath.argtypes = [ctypes.c_char_p, DWORD, HANDLE, ctypes.POINTER(ctypes.c_wchar_p)]
CoTaskMemFree = ctypes.windll.ole32.CoTaskMemFree
CoTaskMemFree.argtypes = [ctypes.c_void_p]
def known_folder_path(guid: uuid.UUID) -> Optional[str]:
"""Look up a Windows GUID to actual folder path name."""
buf = ctypes.c_wchar_p()
if SHGetKnownFolderPath(ctypes.create_string_buffer(guid.bytes_le), 0, 0, ctypes.byref(buf)):
return None
retval = buf.value # copy data
CoTaskMemFree(buf) # and free original
return retval
class WinConfig(AbstractConfig):
"""Implementation of AbstractConfig for Windows."""
def __init__(self, do_winsparkle=True) -> None:
self.app_dir_path = pathlib.Path(str(known_folder_path(FOLDERID_LocalAppData))) / appname
self.app_dir_path.mkdir(exist_ok=True)
self.plugin_dir_path = self.app_dir_path / 'plugins'
self.plugin_dir_path.mkdir(exist_ok=True)
if getattr(sys, 'frozen', False):
self.respath_path = pathlib.Path(sys.executable).parent
self.internal_plugin_dir_path = self.respath_path / 'plugins'
else:
self.respath_path = pathlib.Path(__file__).parent.parent
self.internal_plugin_dir_path = self.respath_path / 'plugins'
self.home_path = pathlib.Path.home()
journal_dir_str = known_folder_path(FOLDERID_SavedGames)
journaldir = pathlib.Path(journal_dir_str) if journal_dir_str is not None else None
self.default_journal_dir_path = None # type: ignore
if journaldir is not None:
self.default_journal_dir_path = journaldir / 'Frontier Developments' / 'Elite Dangerous'
create_key_defaults = functools.partial(
winreg.CreateKeyEx,
key=winreg.HKEY_CURRENT_USER,
access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY,
)
try:
self.__reg_handle: winreg.HKEYType = create_key_defaults(
sub_key=r'Software\Marginal\EDMarketConnector'
)
if do_winsparkle:
self.__setup_winsparkle()
except OSError:
logger.exception('could not create required registry keys')
raise
self.identifier = applongname
if (outdir_str := self.get_str('outdir')) is None or not pathlib.Path(outdir_str).is_dir():
docs = known_folder_path(FOLDERID_Documents)
self.set('outdir', docs if docs is not None else self.home)
def __setup_winsparkle(self):
"""Ensure the necessary Registry keys for WinSparkle are present."""
create_key_defaults = functools.partial(
winreg.CreateKeyEx,
key=winreg.HKEY_CURRENT_USER,
access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY,
)
try:
edcd_handle: winreg.HKEYType = create_key_defaults(sub_key=r'Software\EDCD\EDMarketConnector')
winsparkle_reg: winreg.HKEYType = winreg.CreateKeyEx(
edcd_handle, sub_key='WinSparkle', access=winreg.KEY_ALL_ACCESS | winreg.KEY_WOW64_64KEY
)
except OSError:
logger.exception('could not open WinSparkle handle')
raise
# set WinSparkle defaults - https://github.com/vslavik/winsparkle/wiki/Registry-Settings
winreg.SetValueEx(
winsparkle_reg, 'UpdateInterval', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, str(update_interval)
)
try:
winreg.QueryValueEx(winsparkle_reg, 'CheckForUpdates')
except FileNotFoundError:
# Key doesn't exist, set it to a default
winreg.SetValueEx(winsparkle_reg, 'CheckForUpdates', REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, '1')
winsparkle_reg.Close()
edcd_handle.Close()
def __get_regentry(self, key: str) -> Union[None, list, str, int]:
"""Access the Registry for the raw entry."""
try:
value, _type = winreg.QueryValueEx(self.__reg_handle, key)
except FileNotFoundError:
# Key doesn't exist
return None
# The type returned is actually as we'd expect for each of these. The casts are here for type checkers and
# For programmers who want to actually know what is going on
if _type == winreg.REG_SZ:
return str(value)
elif _type == winreg.REG_DWORD:
return int(value)
elif _type == winreg.REG_MULTI_SZ:
return list(value)
else:
logger.warning(f'registry key {key=} returned unknown type {_type=} {value=}')
return None
def get_str(self, key: str, *, default: str = None) -> str:
"""
Return the string referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_str`.
"""
res = self.__get_regentry(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, str):
raise ValueError(f'Data from registry is not a string: {type(res)=} {res=}')
return res
def get_list(self, key: str, *, default: list = None) -> list:
"""
Return the list referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_list`.
"""
res = self.__get_regentry(key)
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
elif not isinstance(res, list):
raise ValueError(f'Data from registry is not a list: {type(res)=} {res}')
return res
def get_int(self, key: str, *, default: int = 0) -> int:
"""
Return the int referred to by key if it exists in the config.
Implements :meth:`AbstractConfig.get_int`.
"""
res = self.__get_regentry(key)
if res is None:
return default
if not isinstance(res, int):
raise ValueError(f'Data from registry is not an int: {type(res)=} {res}')
return res
def get_bool(self, key: str, *, default: bool = None) -> bool:
"""
Return the bool referred to by the given key if it exists, or the default.
Implements :meth:`AbstractConfig.get_bool`.
"""
res = self.get_int(key, default=default) # type: ignore
if res is None:
return default # type: ignore # Yes it could be None, but we're _assuming_ that people gave us a default
return bool(res)
def set(self, key: str, val: Union[int, str, List[str], bool]) -> None:
"""
Set the given key's data to the given value.
Implements :meth:`AbstractConfig.set`.
"""
reg_type = None
if isinstance(val, str):
reg_type = winreg.REG_SZ
winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, winreg.REG_SZ, val)
elif isinstance(val, int): # The original code checked for numbers.Integral, I dont think that is needed.
reg_type = winreg.REG_DWORD
elif isinstance(val, list):
reg_type = winreg.REG_MULTI_SZ
elif isinstance(val, bool):
reg_type = winreg.REG_DWORD
val = int(val)
else:
raise ValueError(f'Unexpected type for value {type(val)=}')
# Its complaining about the list, it works, tested on windows, ignored.
winreg.SetValueEx(self.__reg_handle, key, REG_RESERVED_ALWAYS_ZERO, reg_type, val) # type: ignore
def delete(self, key: str, *, suppress=False) -> None:
"""
Delete the given key from the config.
'key' is relative to the base Registry path we use.
Implements :meth:`AbstractConfig.delete`.
"""
try:
winreg.DeleteValue(self.__reg_handle, key)
except OSError:
if suppress:
return
raise
def save(self) -> None:
"""
Save the configuration.
Not required for WinConfig as Registry keys are flushed on write.
"""
pass
def close(self):
"""
Close this config and release any associated resources.
Implements :meth:`AbstractConfig.close`.
"""
self.__reg_handle.Close()

@ -1 +1 @@
Subproject commit e499f68469bb4c3d7cecbc91ab67bd4a0bc40993
Subproject commit 5ac8a2474e931bbf7cae351f7c987bc40af81012

View File

@ -1,5 +1,12 @@
#!/usr/bin/env python3
"""Build ship and module databases from https://github.com/EDCD/coriolis-data/ ."""
"""
Build ship and module databases from https://github.com/EDCD/coriolis-data/ .
This script also utilise the file outfitting.csv. Due to how collate.py
both reads and writes to this file a local copy is used, in the root of the
project structure, is used for this purpose. If you want to utilise the
FDevIDs/ version of the file, copy it over the local one.
"""
import csv

View File

@ -2,11 +2,11 @@
import json
import pathlib
import sys
import time
import tkinter as tk
from calendar import timegm
from os.path import getsize, isdir, isfile
from sys import platform
from typing import Any, Dict
from config import config
@ -14,11 +14,11 @@ from EDMCLogging import get_main_logger
logger = get_main_logger()
if platform == 'darwin':
if sys.platform == 'darwin':
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
elif platform == 'win32':
elif sys.platform == 'win32':
from watchdog.events import FileSystemEventHandler
from watchdog.observers import Observer
@ -71,26 +71,25 @@ class Dashboard(FileSystemEventHandler):
# File system events are unreliable/non-existent over network drives on Linux.
# We can't easily tell whether a path points to a network drive, so assume
# any non-standard logdir might be on a network drive and poll instead.
polling = platform != 'win32'
if not polling and not self.observer:
if not (sys.platform != 'win32') and not self.observer:
logger.debug('Setting up observer...')
self.observer = Observer()
self.observer.daemon = True
self.observer.start()
logger.debug('Done')
elif polling and self.observer:
elif (sys.platform != 'win32') and self.observer:
logger.debug('Using polling, stopping observer...')
self.observer.stop()
self.observer = None # type: ignore
logger.debug('Done')
if not self.observed and not polling:
if not self.observed and not (sys.platform != 'win32'):
logger.debug('Starting observer...')
self.observed = self.observer.schedule(self, self.currentdir)
logger.debug('Done')
logger.info(f'{polling and "Polling" or "Monitoring"} Dashboard "{self.currentdir}"')
logger.info(f'{(sys.platform != "win32") and "Polling" or "Monitoring"} Dashboard "{self.currentdir}"')
# Even if we're not intending to poll, poll at least once to process pre-existing
# data and to check whether the watchdog thread has crashed due to events not

View File

@ -1,10 +1,12 @@
"""Simple HTTP listener to be used with debugging various EDMC sends."""
import gzip
import json
import pathlib
import tempfile
import threading
import zlib
from http import server
from typing import Any, Callable, Tuple, Union
from typing import Any, Callable, Literal, Tuple, Union
from urllib.parse import parse_qs
from config import appname
@ -30,8 +32,11 @@ class LoggingHandler(server.BaseHTTPRequestHandler):
def do_POST(self) -> None: # noqa: N802 # I cant change it
"""Handle POST."""
logger.info(f"Received a POST for {self.path!r}!")
data = self.rfile.read(int(self.headers['Content-Length'])).decode('utf-8', errors='replace')
to_save = data
data_raw: bytes = self.rfile.read(int(self.headers['Content-Length']))
encoding = self.headers.get('Content-Encoding')
to_save = data = self.get_printable(data_raw, encoding)
target_path = self.path
if len(target_path) > 1 and target_path[0] == '/':
@ -42,7 +47,7 @@ class LoggingHandler(server.BaseHTTPRequestHandler):
response: Union[Callable[[str], str], str, None] = DEFAULT_RESPONSES.get(target_path)
if callable(response):
response = response(data)
response = response(to_save)
self.send_response_only(200, "OK")
if response is not None:
@ -64,12 +69,37 @@ class LoggingHandler(server.BaseHTTPRequestHandler):
target_file = output_data_path / (safe_file_name(target_path) + '.log')
if target_file.parent != output_data_path:
logger.warning(f"REFUSING TO WRITE FILE THAT ISN'T IN THE RIGHT PLACE! {target_file=}")
logger.warning(f'DATA FOLLOWS\n{data}')
logger.warning(f'DATA FOLLOWS\n{data}') # type: ignore # mypy thinks data is a byte string here
return
with output_lock, target_file.open('a') as f:
f.write(to_save + "\n\n")
@staticmethod
def get_printable(data: bytes, compression: Literal['deflate'] | Literal['gzip'] | str | None = None) -> str:
"""
Convert an incoming data stream into a string.
:param data: The data to convert
:param compression: The compression to remove, defaults to None
:raises ValueError: If compression is unknown
:return: printable strings
"""
ret: bytes = b''
if compression is None:
ret = data
elif compression == 'deflate':
ret = zlib.decompress(data)
elif compression == 'gzip':
ret = gzip.decompress(data)
else:
raise ValueError(f'Unknown encoding for data {compression!r}')
return ret.decode('utf-8', errors='replace')
def safe_file_name(name: str):
"""
@ -103,6 +133,7 @@ def generate_inara_response(raw_data: str) -> str:
def extract_edsm_data(data: str) -> dict[str, Any]:
"""Extract relevant data from edsm data."""
res = parse_qs(data)
return {name: data[0] for name, data in res.items()}

View File

@ -17,7 +17,8 @@ this document aims to enable anyone to quickly get up to speed on how to:
available versions and asks the user to upgrade.
Note that for Windows only a 32-bit application is supported at this time.
This is principally due to the Windows Registry handling in config.py.
This is principally due to the Windows Registry handling in
`config/windows.py`.
# Environment
@ -103,7 +104,7 @@ that.
registry entries on Windows.
1. Application names, version and URL of the file with latest release
information. These are all in the `config.py` file. See the
information. These are all in the `config/__init__.py` file. See the
`from config import ...` lines in setup.py.
1. `appname`: The short appname, e.g. 'EDMarketConnector'
2. `applongname`: The long appname, e.g. 'E:D Market Connector'
@ -206,24 +207,24 @@ following.
1. You should by this time know what changes are going into the release, and
which branch (stable or beta) you'll be ultimately updating.
1. So as to make backing out any mistakes easier create a new branch for this
2. So as to make backing out any mistakes easier create a new branch for this
release, using a name like `release-4.0.2`. Do not use the tag
`Release/4.0.2` form, that could cause confusion.
1. `git checkout stable` # Or whichever other branch is appropriate.
1. `git pull origin` # Ensures local branch is up to date.
1. `git checkout -b release-4.0.2`
1. Get all the relevant code changes into this branch. This might mean
3. Get all the relevant code changes into this branch. This might mean
merging from another branch, such as an issue-specific one, or possibly
cherry-picking commits. See [Contributing Guidelines](../Contributing.md)
for how such branches should be named.
1. You should have already decided on the new
[Version String](#Version-Strings), as it's specified in `config.py`. You'll
need to redo the `.msi` build if you forgot. **Remember to do a fresh git
commit for this change.**
4. You should have already decided on the new
[Version String](#Version-Strings), as it's specified in `config/__init__.py`.
You'll need to redo the `.msi` build if you forgot. **Remember to do a fresh
git commit for this change.**
1. Prepare a changelog text for the release. You'll need this both for the
5. Prepare a changelog text for the release. You'll need this both for the
GitHub release and the contents of the `edmarketconnector.xml` file if making
a `stable` release, as well as any social media posts you make.
1. The primary location of the changelog is [Changelog.md](../Changelog.md) -
@ -408,7 +409,7 @@ changelog text to the correct section(s):
`https://raw.githubusercontent.com/EDCD/EDMarketConnector/releases/edmarketconnector.xml`
as per `config.py` `update_feed`.
as per `config/__init__.py` `update_feed`.
NB: It can take some time for GitHub to show the changed
edmarketconnector.xml contents to all users.
@ -438,6 +439,8 @@ When changing the Python version (Major.Minor.Patch) used:
1. `.github/workflows/windows-build.yml` needs updating to have the GitHub
based build use the correct version.
2. `ChangeLog.md` - The `We now test against, and package with, Python
M.m.P.` line.
1. Major or Minor level changes:
@ -446,3 +449,5 @@ When changing the Python version (Major.Minor.Patch) used:
pythonXX.dll file.
3. `.pre-commit-config.yaml` will need the `default_language_version`
section updated to the appropriate version.
4. All `.github/workflows/` files will need to be citing the correct
version in any `uses: actions/setup-python` section.

View File

@ -1,10 +1,10 @@
"""Implements locking of Journal directory."""
import pathlib
import sys
import tkinter as tk
from enum import Enum
from os import getpid as os_getpid
from sys import platform
from tkinter import ttk
from typing import TYPE_CHECKING, Callable, Optional
@ -94,7 +94,7 @@ class JournalLock:
:return: LockResult - See the class Enum definition
"""
if platform == 'win32':
if sys.platform == 'win32':
logger.trace_if('journal-lock', 'win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
@ -143,7 +143,7 @@ class JournalLock:
return True # We weren't locked, and still aren't
unlocked = False
if platform == 'win32':
if sys.platform == 'win32':
logger.trace_if('journal-lock', 'win32, using msvcrt')
# win32 doesn't have fcntl, so we have to use msvcrt
import msvcrt
@ -206,10 +206,10 @@ class JournalLock:
self.title(_('Journal directory already locked'))
# remove decoration
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
elif sys.platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')

71
l10n.py
View File

@ -12,7 +12,6 @@ import warnings
from collections import OrderedDict
from contextlib import suppress
from os.path import basename, dirname, isdir, isfile, join
from sys import platform
from typing import TYPE_CHECKING, Dict, Iterable, Optional, Set, TextIO, Union, cast
if TYPE_CHECKING:
@ -37,12 +36,12 @@ LANGUAGE_ID = '!Language'
LOCALISATION_DIR = 'L10n'
if platform == 'darwin':
if sys.platform == 'darwin':
from Foundation import ( # type: ignore # exists on Darwin
NSLocale, NSNumberFormatter, NSNumberFormatterDecimalStyle
)
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
from ctypes.wintypes import BOOL, DWORD, LPCVOID, LPCWSTR, LPWSTR
if TYPE_CHECKING:
@ -176,7 +175,7 @@ class _Translations:
def available(self) -> Set[str]:
"""Return a list of available language codes."""
path = self.respath()
if getattr(sys, 'frozen', False) and platform == 'darwin':
if getattr(sys, 'frozen', False) and sys.platform == 'darwin':
available = {
x[:-len('.lproj')] for x in os.listdir(path)
if x.endswith('.lproj') and isfile(join(x, 'Localizable.strings'))
@ -204,7 +203,7 @@ class _Translations:
def respath(self) -> pathlib.Path:
"""Path to localisation files."""
if getattr(sys, 'frozen', False):
if platform == 'darwin':
if sys.platform == 'darwin':
return (pathlib.Path(sys.executable).parents[0] / os.pardir / 'Resources').resolve()
return pathlib.Path(dirname(sys.executable)) / LOCALISATION_DIR
@ -233,7 +232,7 @@ class _Translations:
except OSError:
logger.exception(f'could not open {f}')
elif getattr(sys, 'frozen', False) and platform == 'darwin':
elif getattr(sys, 'frozen', False) and sys.platform == 'darwin':
return (self.respath() / f'{lang}.lproj' / 'Localizable.strings').open('r', encoding='utf-16')
return (self.respath() / f'{lang}.strings').open('r', encoding='utf-8')
@ -243,7 +242,7 @@ class _Locale:
"""Locale holds a few utility methods to convert data to and from localized versions."""
def __init__(self) -> None:
if platform == 'darwin':
if sys.platform == 'darwin':
self.int_formatter = NSNumberFormatter.alloc().init()
self.int_formatter.setNumberStyle_(NSNumberFormatterDecimalStyle)
self.float_formatter = NSNumberFormatter.alloc().init()
@ -276,7 +275,7 @@ class _Locale:
if decimals == 0 and not isinstance(number, numbers.Integral):
number = int(round(number))
if platform == 'darwin':
if sys.platform == 'darwin':
if not decimals and isinstance(number, numbers.Integral):
return self.int_formatter.stringFromNumber_(number)
@ -298,7 +297,7 @@ class _Locale:
:param string: The string to convert
:return: None if the string cannot be parsed, otherwise an int or float dependant on input data.
"""
if platform == 'darwin':
if sys.platform == 'darwin':
return self.float_formatter.numberFromString_(string)
with suppress(ValueError):
@ -309,7 +308,7 @@ class _Locale:
return None
def preferred_languages(self) -> Iterable[str]:
def preferred_languages(self) -> Iterable[str]: # noqa: CCR001
"""
Return a list of preferred language codes.
@ -320,34 +319,46 @@ class _Locale:
:return: The preferred language list
"""
if platform == 'darwin':
return NSLocale.preferredLanguages()
languages: Iterable[str]
if sys.platform == 'darwin':
languages = NSLocale.preferredLanguages()
elif platform != 'win32':
elif sys.platform != 'win32':
# POSIX
lang = locale.getlocale()[0]
return lang and [lang.replace('_', '-')] or []
languages = lang and [lang.replace('_', '-')] or []
def wszarray_to_list(array):
offset = 0
while offset < len(array):
sz = ctypes.wstring_at(ctypes.addressof(array) + offset*2)
if sz:
yield sz
offset += len(sz)+1
else:
def wszarray_to_list(array):
offset = 0
while offset < len(array):
sz = ctypes.wstring_at(ctypes.addressof(array) + offset*2)
if sz:
yield sz
offset += len(sz)+1
else:
break
else:
break
num = ctypes.c_ulong()
size = ctypes.c_ulong(0)
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, ctypes.byref(num), None, ctypes.byref(size)) and size.value:
buf = ctypes.create_unicode_buffer(size.value)
num = ctypes.c_ulong()
size = ctypes.c_ulong(0)
languages = []
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME, ctypes.byref(num), None, ctypes.byref(size)
) and size.value:
buf = ctypes.create_unicode_buffer(size.value)
if GetUserPreferredUILanguages(MUI_LANGUAGE_NAME, ctypes.byref(num), ctypes.byref(buf), ctypes.byref(size)):
return wszarray_to_list(buf)
if GetUserPreferredUILanguages(
MUI_LANGUAGE_NAME, ctypes.byref(num), ctypes.byref(buf), ctypes.byref(size)
):
languages = wszarray_to_list(buf)
return []
# HACK: <n/a> | 2021-12-11: OneSky calls "Chinese Simplified" "zh-Hans"
# in the name of the file, but that will be zh-CN in terms of
# locale. So map zh-CN -> zh-Hans
languages = ['zh-Hans' if lang == 'zh-CN' else lang for lang in languages]
return languages
# singletons

View File

@ -1,16 +1,19 @@
"""Monitor for new Journal files and contents of latest."""
# v [sic]
# spell-checker: words onfoot unforseen relog fsdjump suitloadoutid slotid suitid loadoutid fauto Intimidator
# spell-checker: words joinacrew quitacrew sellshiponrebuy newbal navroute npccrewpaidwage sauto
import json
import pathlib
import queue
import re
import sys
import threading
from calendar import timegm
from collections import OrderedDict, defaultdict
from os import SEEK_END, SEEK_SET, listdir
from os.path import basename, expanduser, isdir, join
from sys import platform
from time import gmtime, localtime, sleep, strftime, strptime, time
from time import gmtime, localtime, mktime, sleep, strftime, strptime, time
from typing import TYPE_CHECKING, Any, BinaryIO, Dict, List, MutableMapping, Optional
from typing import OrderedDict as OrderedDictT
from typing import Tuple
@ -23,14 +26,17 @@ from config import config
from edmc_data import edmc_suit_shortnames, edmc_suit_symbol_localised
from EDMCLogging import get_main_logger
# spell-checker: words navroute
logger = get_main_logger()
STARTUP = 'journal.startup'
MAX_NAVROUTE_DISCREPANCY = 5 # Timestamp difference in seconds
if TYPE_CHECKING:
def _(x: str) -> str:
return x
if platform == 'darwin':
if sys.platform == 'darwin':
from fcntl import fcntl
from AppKit import NSWorkspace
@ -38,7 +44,7 @@ if platform == 'darwin':
from watchdog.observers import Observer
F_GLOBAL_NOCACHE = 55
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
from ctypes.wintypes import BOOL, HWND, LPARAM, LPWSTR
@ -59,6 +65,10 @@ elif platform == 'win32':
else:
# Linux's inotify doesn't work over CIFS or NFS, so poll
FileSystemEventHandler = object # dummy
if TYPE_CHECKING:
# this isn't ever used, but this will make type checking happy
from watchdog.events import FileCreatedEvent
from watchdog.observers import Observer
# Journal handler
@ -92,6 +102,8 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
# If 1 or 2 a LoadGame event will happen when the game goes live.
# If 3 we need to inject a special 'StartUp' event since consumers won't see the LoadGame event.
self.live = False
# And whilst we're parsing *only to catch up on state*, we might not want to fully process some things
self.catching_up = False
self.game_was_running = False # For generation of the "ShutDown" event
@ -111,6 +123,9 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
self.systempopulation: Optional[int] = None
self.started: Optional[int] = None # Timestamp of the LoadGame event
self._navroute_retries_remaining = 0
self._last_navroute_journal_timestamp: Optional[float] = None
self.__init_state()
def __init_state(self) -> None:
@ -165,8 +180,11 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
'SuitLoadouts': {},
'Taxi': None, # True whenever we are _in_ a taxi. ie, this is reset on Disembark etc.
'Dropship': None, # Best effort as to whether or not the above taxi is a dropship.
'StarPos': None, # Best effort current system's galaxy position.
'Body': None,
'BodyType': None,
'NavRoute': None,
}
def start(self, root: 'tkinter.Tk') -> bool: # noqa: CCR001
@ -215,7 +233,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
# File system events are unreliable/non-existent over network drives on Linux.
# We can't easily tell whether a path points to a network drive, so assume
# any non-standard logdir might be on a network drive and poll instead.
polling = bool(config.get_str('journaldir')) and platform != 'win32'
polling = bool(config.get_str('journaldir')) and sys.platform != 'win32'
if not polling and not self.observer:
logger.debug('Not polling, no observer, starting an observer...')
self.observer = Observer()
@ -272,6 +290,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
if self.observed:
logger.debug('self.observed: Calling unschedule_all()')
self.observed = None
assert self.observer is not None, 'Observer was none but it is in use?'
self.observer.unschedule_all()
logger.debug('Done')
@ -331,9 +350,10 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
logfile = self.logfile
if logfile:
loghandle: BinaryIO = open(logfile, 'rb', 0) # unbuffered
if platform == 'darwin':
if sys.platform == 'darwin':
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
self.catching_up = True
for line in loghandle:
try:
if b'"event":"Location"' in line:
@ -344,6 +364,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
except Exception as ex:
logger.debug(f'Invalid journal entry:\n{line!r}\n', exc_info=ex)
self.catching_up = False
log_pos = loghandle.tell()
else:
@ -382,9 +403,13 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
self.event_queue.put(None)
self.live = False
emitter = None
# Watchdog thread -- there is a way to get this by using self.observer.emitters and checking for an attribute:
# watch, but that may have unforseen differences in behaviour.
emitter = self.observed and self.observer._emitter_for_watch[self.observed] # Note: Uses undocumented attribute
if self.observed:
assert self.observer is not None, 'self.observer is None but also in use?'
# Note: Uses undocumented attribute
emitter = self.observed and self.observer._emitter_for_watch[self.observed]
logger.debug('Entering loop...')
while True:
@ -440,7 +465,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
if logfile:
loghandle = open(logfile, 'rb', 0) # unbuffered
if platform == 'darwin':
if sys.platform == 'darwin':
fcntl(loghandle, F_GLOBAL_NOCACHE, -1) # required to avoid corruption on macOS over SMB
log_pos = 0
@ -494,6 +519,8 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
entry: MutableMapping[str, Any] = json.loads(line, object_pairs_hook=OrderedDict)
entry['timestamp'] # we expect this to exist # TODO: replace with assert? or an if key in check
self.__navroute_retry()
event_type = entry['event'].lower()
if event_type == 'fileheader':
self.live = False
@ -685,7 +712,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
# This event is logged when a player (on foot) gets into a ship or SRV
# Parameters:
# • SRV: true if getting into SRV, false if getting into a ship
# • Taxi: true when boarding a taxi transposrt ship
# • Taxi: true when boarding a taxi transport ship
# • Multicrew: true when boarding another players vessel
# • ID: players ship ID (if players own vessel)
# • StarSystem
@ -713,7 +740,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
#
# Parameters:
# • SRV: true if getting out of SRV, false if getting out of a ship
# • Taxi: true when getting out of a taxi transposrt ship
# • Taxi: true when getting out of a taxi transport ship
# • Multicrew: true when getting out of another players vessel
# • ID: players ship ID (if players own vessel)
# • StarSystem
@ -775,13 +802,18 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
self.state['BodyType'] = None
if 'StarPos' in entry:
self.coordinates = tuple(entry['StarPos']) # type: ignore
# Plugins need this as well, so copy in state
self.state['StarPos'] = self.coordinates = tuple(entry['StarPos']) # type: ignore
self.systemaddress = entry.get('SystemAddress')
self.systempopulation = entry.get('Population')
self.system = 'CQC' if entry['StarSystem'] == 'ProvingGround' else entry['StarSystem']
if entry['StarSystem'] == 'ProvingGround':
self.system = 'CQC'
else:
self.system = entry['StarSystem']
self.station = entry.get('StationName') # May be None
# If on foot in-station 'Docked' is false, but we have a
@ -1304,17 +1336,15 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
self.state['Credits'] += entry.get('Refund', 0)
self.state['Taxi'] = False
elif event_type == 'navroute':
elif event_type == 'navroute' and not self.catching_up:
# assume we've failed out the gate, then pull it back if things are fine
self._last_navroute_journal_timestamp = mktime(strptime(entry['timestamp'], '%Y-%m-%dT%H:%M:%SZ'))
self._navroute_retries_remaining = 11
# Added in ED 3.7 - multi-hop route details in NavRoute.json
with open(join(self.currentdir, 'NavRoute.json'), 'rb') as rf: # type: ignore
try:
entry = json.load(rf)
except json.JSONDecodeError:
logger.exception('Failed decoding NavRoute.json', exc_info=True)
else:
self.state['NavRoute'] = entry
# rather than duplicating this, lets just call the function
if self.__navroute_retry():
entry = self.state['NavRoute']
elif event_type == 'moduleinfo':
with open(join(self.currentdir, 'ModulesInfo.json'), 'rb') as mf: # type: ignore
@ -1398,7 +1428,7 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
category = self.category(received['Category'])
state_category[received['Material']] += received['Quantity']
elif event_type == 'EngineerCraft' or (
elif event_type == 'engineercraft' or (
event_type == 'engineerlegacyconvert' and not entry.get('IsPreview')
):
@ -1930,12 +1960,12 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
:return: bool - True if the game is running.
"""
if platform == 'darwin':
if sys.platform == 'darwin':
for app in NSWorkspace.sharedWorkspace().runningApplications():
if app.bundleIdentifier() == 'uk.co.frontier.EliteDangerous':
return True
elif platform == 'win32':
elif sys.platform == 'win32':
def WindowTitle(h): # noqa: N802 # type: ignore
if h:
length = GetWindowTextLength(h) + 1
@ -2157,6 +2187,72 @@ class EDLogs(FileSystemEventHandler): # type: ignore # See below
return slots
def _parse_navroute_file(self) -> Optional[dict[str, Any]]:
"""Read and parse NavRoute.json."""
if self.currentdir is None:
raise ValueError('currentdir unset')
try:
with open(join(self.currentdir, 'NavRoute.json'), 'r') as f:
raw = f.read()
except Exception as e:
logger.exception(f'Could not open navroute file. Bailing: {e}')
return None
try:
data = json.loads(raw)
except json.JSONDecodeError:
logger.exception('Failed to decode NavRoute.json', exc_info=True)
return None
if 'timestamp' not in data: # quick sanity check
return None
return data
@staticmethod
def _parse_journal_timestamp(source: str) -> float:
return mktime(strptime(source, '%Y-%m-%dT%H:%M:%SZ'))
def __navroute_retry(self) -> bool:
"""Retry reading navroute files."""
if self._navroute_retries_remaining == 0:
return False
logger.info(f'Navroute read retry [{self._navroute_retries_remaining}]')
self._navroute_retries_remaining -= 1
if self._last_navroute_journal_timestamp is None:
logger.critical('Asked to retry for navroute but also no set time to compare? This is a bug.')
return False
if (file := self._parse_navroute_file()) is None:
logger.debug(
'Failed to parse NavRoute.json. '
+ ('Trying again' if self._navroute_retries_remaining > 0 else 'Giving up')
)
return False
# _parse_navroute_file verifies that this exists for us
file_time = self._parse_journal_timestamp(file['timestamp'])
if abs(file_time - self._last_navroute_journal_timestamp) > MAX_NAVROUTE_DISCREPANCY:
logger.debug(
f'Time discrepancy of more than {MAX_NAVROUTE_DISCREPANCY}s --'
f' ({abs(file_time - self._last_navroute_journal_timestamp)}).'
f' {"Trying again" if self._navroute_retries_remaining > 0 else "Giving up"}.'
)
return False
# everything is good, lets set what we need to and make sure we dont try again
logger.info('Successfully read NavRoute file for last NavRoute event.')
self.state['NavRoute'] = file
self._navroute_retries_remaining = 0
self._last_navroute_journal_timestamp = None
return True
# singleton
monitor = EDLogs()

View File

@ -4,22 +4,21 @@
# - OSX: page background should be a darker gray than systemWindowBody
# selected tab foreground should be White when the window is active
#
from sys import platform
import sys
import tkinter as tk
from tkinter import ttk
# Entire file may be imported by plugins
# Can't do this with styles on OSX - http://www.tkdocs.com/tutorial/styles.html#whydifficult
if platform == 'darwin':
if sys.platform == 'darwin':
from platform import mac_ver
PAGEFG = 'systemButtonText'
PAGEBG = 'systemButtonActiveDarkShadow'
elif platform == 'win32':
elif sys.platform == 'win32':
PAGEFG = 'SystemWindowText'
PAGEBG = 'SystemWindow' # typically white
PAGEBG = 'SystemWindow' # typically white
class Notebook(ttk.Notebook):
@ -29,14 +28,14 @@ class Notebook(ttk.Notebook):
ttk.Notebook.__init__(self, master, **kw)
style = ttk.Style()
if platform=='darwin':
if list(map(int, mac_ver()[0].split('.'))) >= [10,10]:
if sys.platform == 'darwin':
if list(map(int, mac_ver()[0].split('.'))) >= [10, 10]:
# Hack for tab appearance with 8.5 on Yosemite & El Capitan. For proper fix see
# https://github.com/tcltk/tk/commit/55c4dfca9353bbd69bbcec5d63bf1c8dfb461e25
style.configure('TNotebook.Tab', padding=(12,10,12,2))
style.configure('TNotebook.Tab', padding=(12, 10, 12, 2))
style.map('TNotebook.Tab', foreground=[('selected', '!background', 'systemWhite')])
self.grid(sticky=tk.NSEW) # Already padded apropriately
elif platform == 'win32':
self.grid(sticky=tk.NSEW) # Already padded apropriately
elif sys.platform == 'win32':
style.configure('nb.TFrame', background=PAGEBG)
style.configure('nb.TButton', background=PAGEBG)
style.configure('nb.TCheckbutton', foreground=PAGEFG, background=PAGEBG)
@ -47,56 +46,60 @@ class Notebook(ttk.Notebook):
self.grid(padx=10, pady=10, sticky=tk.NSEW)
class Frame(platform == 'darwin' and tk.Frame or ttk.Frame):
class Frame(sys.platform == 'darwin' and tk.Frame or ttk.Frame):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['background'] = kw.pop('background', PAGEBG)
tk.Frame.__init__(self, master, **kw)
tk.Frame(self).grid(pady=5)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Frame.__init__(self, master, style='nb.TFrame', **kw)
ttk.Frame(self).grid(pady=5) # top spacer
ttk.Frame(self).grid(pady=5) # top spacer
else:
ttk.Frame.__init__(self, master, **kw)
ttk.Frame(self).grid(pady=5) # top spacer
self.configure(takefocus = 1) # let the frame take focus so that no particular child is focused
ttk.Frame(self).grid(pady=5) # top spacer
self.configure(takefocus=1) # let the frame take focus so that no particular child is focused
class Label(tk.Label):
def __init__(self, master=None, **kw):
if platform in ['darwin', 'win32']:
if sys.platform in ['darwin', 'win32']:
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
else:
kw['foreground'] = kw.pop('foreground', ttk.Style().lookup('TLabel', 'foreground'))
kw['background'] = kw.pop('background', ttk.Style().lookup('TLabel', 'background'))
tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms
tk.Label.__init__(self, master, **kw) # Just use tk.Label on all platforms
class Entry(platform == 'darwin' and tk.Entry or ttk.Entry):
class Entry(sys.platform == 'darwin' and tk.Entry or ttk.Entry):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG)
tk.Entry.__init__(self, master, **kw)
else:
ttk.Entry.__init__(self, master, **kw)
class Button(platform == 'darwin' and tk.Button or ttk.Button):
class Button(sys.platform == 'darwin' and tk.Button or ttk.Button):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['highlightbackground'] = kw.pop('highlightbackground', PAGEBG)
tk.Button.__init__(self, master, **kw)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Button.__init__(self, master, style='nb.TButton', **kw)
else:
ttk.Button.__init__(self, master, **kw)
class ColoredButton(platform == 'darwin' and tk.Label or tk.Button):
class ColoredButton(sys.platform == 'darwin' and tk.Label or tk.Button):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
# Can't set Button background on OSX, so use a Label instead
kw['relief'] = kw.pop('relief', tk.RAISED)
self._command = kw.pop('command', None)
@ -105,52 +108,55 @@ class ColoredButton(platform == 'darwin' and tk.Label or tk.Button):
else:
tk.Button.__init__(self, master, **kw)
if platform == 'darwin':
if sys.platform == 'darwin':
def _press(self, event):
self._command()
class Checkbutton(platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton):
class Checkbutton(sys.platform == 'darwin' and tk.Checkbutton or ttk.Checkbutton):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
tk.Checkbutton.__init__(self, master, **kw)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Checkbutton.__init__(self, master, style='nb.TCheckbutton', **kw)
else:
ttk.Checkbutton.__init__(self, master, **kw)
class Radiobutton(platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton):
class Radiobutton(sys.platform == 'darwin' and tk.Radiobutton or ttk.Radiobutton):
def __init__(self, master=None, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
kw['foreground'] = kw.pop('foreground', PAGEFG)
kw['background'] = kw.pop('background', PAGEBG)
tk.Radiobutton.__init__(self, master, **kw)
elif platform == 'win32':
elif sys.platform == 'win32':
ttk.Radiobutton.__init__(self, master, style='nb.TRadiobutton', **kw)
else:
ttk.Radiobutton.__init__(self, master, **kw)
class OptionMenu(platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu):
class OptionMenu(sys.platform == 'darwin' and tk.OptionMenu or ttk.OptionMenu):
def __init__(self, master, variable, default=None, *values, **kw):
if platform == 'darwin':
if sys.platform == 'darwin':
variable.set(default)
bg = kw.pop('background', PAGEBG)
tk.OptionMenu.__init__(self, master, variable, *values, **kw)
self['background'] = bg
elif platform == 'win32':
elif sys.platform == 'win32':
# OptionMenu derives from Menubutton at the Python level, so uses Menubutton's style
ttk.OptionMenu.__init__(self, master, variable, default, *values, style='nb.TMenubutton', **kw)
self['menu'].configure(background = PAGEBG)
self['menu'].configure(background=PAGEBG)
# Workaround for https://bugs.python.org/issue25684
for i in range(0, self['menu'].index('end')+1):
self['menu'].entryconfig(i, variable=variable)
else:
ttk.OptionMenu.__init__(self, master, variable, default, *values, **kw)
self['menu'].configure(background = ttk.Style().lookup('TMenu', 'background'))
self['menu'].configure(background=ttk.Style().lookup('TMenu', 'background'))
# Workaround for https://bugs.python.org/issue25684
for i in range(0, self['menu'].index('end')+1):
self['menu'].entryconfig(i, variable=variable)

View File

@ -1,5 +1,27 @@
"""Coriolis ship export."""
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# This is an EDMC 'core' plugin.
#
# All EDMC plugins are *dynamically* loaded at run-time.
#
# We build for Windows using `py2exe`.
#
# `py2exe` can't possibly know about anything in the dynamically loaded
# core plugins.
#
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import base64
import gzip
import io

View File

@ -23,6 +23,28 @@
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# This is an EDMC 'core' plugin.
#
# All EDMC plugins are *dynamically* loaded at run-time.
#
# We build for Windows using `py2exe`.
#
# `py2exe` can't possibly know about anything in the dynamically loaded
# core plugins.
#
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import sys
from typing import TYPE_CHECKING, Any, Optional

View File

@ -1,5 +1,27 @@
"""Handle exporting data to EDDN."""
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# This is an EDMC 'core' plugin.
#
# All EDMC plugins are *dynamically* loaded at run-time.
#
# We build for Windows using `py2exe`.
#
# `py2exe` can't possibly know about anything in the dynamically loaded
# core plugins.
#
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import itertools
import json
import pathlib
@ -10,6 +32,7 @@ from collections import OrderedDict
from os import SEEK_SET
from os.path import join
from platform import system
from textwrap import dedent
from typing import TYPE_CHECKING, Any, Dict, Iterator, List, Mapping, MutableMapping, Optional
from typing import OrderedDict as OrderedDictT
from typing import TextIO, Tuple, Union
@ -21,12 +44,13 @@ import killswitch
import myNotebook as nb # noqa: N813
import plug
from companion import CAPIData, category_map
from config import applongname, appversion_nobuild, config, debug_senders
from config import applongname, appversion_nobuild, config, debug_senders, user_agent
from EDMCLogging import get_main_logger
from monitor import monitor
from myNotebook import Frame
from prefs import prefsVersion
from ttkHyperlinkLabel import HyperlinkLabel
from util import text
if sys.platform != 'win32':
from fcntl import LOCK_EX, LOCK_NB, lockf
@ -125,6 +149,7 @@ class EDDN:
def __init__(self, parent: tk.Tk):
self.parent: tk.Tk = parent
self.session = requests.Session()
self.session.headers['User-Agent'] = user_agent
self.replayfile: Optional[TextIO] = None # For delayed messages
self.replaylog: List[str] = []
@ -167,6 +192,10 @@ class EDDN:
def flush(self):
"""Flush the replay file, clearing any data currently there that is not in the replaylog list."""
if self.replayfile is None:
logger.error('replayfile is None!')
return
self.replayfile.seek(0, SEEK_SET)
self.replayfile.truncate()
for line in self.replaylog:
@ -212,7 +241,20 @@ class EDDN:
('message', msg['message']),
])
r = self.session.post(self.eddn_url, data=json.dumps(to_send), timeout=self.TIMEOUT)
# About the smallest request is going to be (newlines added for brevity):
# {"$schemaRef":"https://eddn.edcd.io/schemas/commodity/3","header":{"softwareName":"E:D Market
# Connector Windows","softwareVersion":"5.3.0-beta4extra","uploaderID":"abcdefghijklm"},"messag
# e":{"systemName":"delphi","stationName":"The Oracle","marketId":128782803,"timestamp":"2022-0
# 1-26T12:00:00Z","commodities":[]}}
#
# Which comes to 315 bytes (including \n) and compresses to 244 bytes. So lets just compress everything
encoded, compressed = text.gzip(json.dumps(to_send, separators=(',', ':')), max_size=0)
headers: None | dict[str, str] = None
if compressed:
headers = {'Content-Encoding': 'gzip'}
r = self.session.post(self.eddn_url, data=encoded, timeout=self.TIMEOUT, headers=headers)
if r.status_code != requests.codes.ok:
# Check if EDDN is still objecting to an empty commodities list
@ -225,18 +267,48 @@ class EDDN:
logger.trace_if('plugin.eddn', "EDDN is still objecting to empty commodities data")
return # We want to silence warnings otherwise
if r.status_code == 413:
extra_data = {
'schema_ref': msg.get('$schemaRef', 'Unset $schemaRef!'),
'sent_data_len': str(len(encoded)),
}
if '/journal/' in extra_data['schema_ref']:
extra_data['event'] = msg.get('message', {}).get('event', 'No Event Set')
self._log_response(r, header_msg='Got a 413 while POSTing data', **extra_data)
return # drop the error
if not self.UNKNOWN_SCHEMA_RE.match(r.text):
logger.debug(
f'''Status from POST wasn't OK:
Status\t{r.status_code}
URL\t{r.url}
Headers\t{r.headers}
Content:\n{r.text}
Msg:\n{msg}'''
)
self._log_response(r, header_msg='Status from POST wasn\'t 200 (OK)')
r.raise_for_status()
def _log_response(
self,
response: requests.Response,
header_msg='Failed to POST to EDDN',
**kwargs
) -> None:
"""
Log a response object with optional additional data.
:param response: The response to log
:param header_msg: A header message to add to the log, defaults to 'Failed to POST to EDDN'
:param kwargs: Any other notes to add, will be added below the main data in the same format.
"""
additional_data = "\n".join(
f'''{name.replace('_', ' ').title():<8}:\t{value}''' for name, value in kwargs.items()
)
logger.debug(dedent(f'''\
{header_msg}:
Status :\t{response.status_code}
URL :\t{response.url}
Headers :\t{response.headers}
Content :\t{response.text}
''')+additional_data)
def sendreplay(self) -> None: # noqa: CCR001
"""Send cached Journal lines to EDDN."""
if not self.replayfile:
@ -717,20 +789,27 @@ Msg:\n{msg}'''
def entry_augment_system_data(
self,
entry: MutableMapping[str, Any],
system_name: str
system_name: str,
system_coordinates: list
) -> Union[str, MutableMapping[str, Any]]:
"""
Augment a journal entry with necessary system data.
:param entry: The journal entry to be augmented.
:param system_name: Name of current star system.
:param systemname_field_name: Name of journal key for system name.
:param system_coordinates: Coordinates of current star system.
:return: The augmented version of entry.
"""
# If 'SystemName' or 'System' is there, it's directly from a journal event.
# If they're not there *and* 'StarSystem' isn't either, then we add the latter.
if 'SystemName' not in entry and 'System' not in entry and 'StarSystem' not in entry:
entry['StarSystem'] = system_name
if system_name is None or not isinstance(system_name, str) or system_name == '':
# Bad assumptions if this is the case
logger.warning(f'No system name in entry, and system_name was not set either! entry:\n{entry!r}\n')
return "passed-in system_name is empty, can't add System"
else:
entry['StarSystem'] = system_name
if 'SystemAddress' not in entry:
if this.systemaddress is None:
@ -740,21 +819,29 @@ Msg:\n{msg}'''
entry['SystemAddress'] = this.systemaddress
if 'StarPos' not in entry:
if not this.coordinates:
logger.warning("this.coordinates is None, can't add StarPos")
return "this.coordinates is None, can't add StarPos"
# Prefer the passed-in, probably monitor.state version
if system_coordinates is not None:
entry['StarPos'] = system_coordinates
entry['StarPos'] = list(this.coordinates)
# TODO: Deprecate in-plugin tracking
elif this.coordinates is not None:
entry['StarPos'] = list(this.coordinates)
else:
logger.warning("Neither this_coordinates or this.coordinates set, can't add StarPos")
return 'No source for adding StarPos to approachsettlement/1 !'
return entry
def export_journal_fssdiscoveryscan(
self, cmdr: str, system: str, is_beta: bool, entry: Mapping[str, Any]
self, cmdr: str, system_name: str, system_starpos: list, is_beta: bool, entry: Mapping[str, Any]
) -> Optional[str]:
"""
Send an FSSDiscoveryScan to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_name: Name of current star system
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
@ -767,7 +854,7 @@ Msg:\n{msg}'''
#######################################################################
# Augmentations
#######################################################################
ret = this.eddn.entry_augment_system_data(entry, system)
ret = this.eddn.entry_augment_system_data(entry, system_name, system_starpos)
if isinstance(ret, str):
return ret
@ -783,13 +870,14 @@ Msg:\n{msg}'''
return None
def export_journal_navbeaconscan(
self, cmdr: str, system_name: str, is_beta: bool, entry: Mapping[str, Any]
self, cmdr: str, system_name: str, system_starpos: list, is_beta: bool, entry: Mapping[str, Any]
) -> Optional[str]:
"""
Send an NavBeaconScan to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_name: Name of the current system.
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
@ -802,7 +890,7 @@ Msg:\n{msg}'''
#######################################################################
# Augmentations
#######################################################################
ret = this.eddn.entry_augment_system_data(entry, system_name)
ret = this.eddn.entry_augment_system_data(entry, system_name, system_starpos)
if isinstance(ret, str):
return ret
@ -817,13 +905,14 @@ Msg:\n{msg}'''
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def export_journal_codexentry(
self, cmdr: str, is_beta: bool, entry: MutableMapping[str, Any]
def export_journal_codexentry( # noqa: CCR001
self, cmdr: str, system_starpos: list, is_beta: bool, entry: MutableMapping[str, Any]
) -> Optional[str]:
"""
Send a CodexEntry to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
@ -858,21 +947,37 @@ Msg:\n{msg}'''
# Augmentations
#######################################################################
# General 'system' augmentations
ret = this.eddn.entry_augment_system_data(entry, entry['System'])
ret = this.eddn.entry_augment_system_data(entry, entry['System'], system_starpos)
if isinstance(ret, str):
return ret
entry = ret
# Set BodyName if it's available from Status.json
if this.status_body_name is not None:
if this.status_body_name is None or not isinstance(this.status_body_name, str):
logger.warning(f'this.status_body_name was not set properly:'
f' "{this.status_body_name}" ({type(this.status_body_name)})')
else:
entry['BodyName'] = this.status_body_name
# Only set BodyID if journal BodyName matches the Status.json one.
# This avoids binary body issues.
if this.status_body_name == this.body_name:
entry['BodyID'] = this.body_id
if this.body_id is not None and isinstance(this.body_id, int):
entry['BodyID'] = this.body_id
else:
logger.warning(f'this.body_id was not set properly: "{this.body_id}" ({type(this.body_id)})')
#######################################################################
for k, v in entry.items():
if v is None or isinstance(v, str) and v == '':
logger.warning(f'post-processing entry contains entry["{k}"] = {v} {(type(v))}')
# We should drop this message and VERY LOUDLY inform the
# user, in the hopes they'll open a bug report with the
# raw Journal event that caused this.
return 'CodexEntry had empty string, PLEASE ALERT THE EDMC DEVELOPERS'
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/codexentry/1{"/test" if is_beta else ""}',
'message': entry
@ -882,12 +987,13 @@ Msg:\n{msg}'''
return None
def export_journal_scanbarycentre(
self, cmdr: str, is_beta: bool, entry: Mapping[str, Any]
self, cmdr: str, system_starpos: list, is_beta: bool, entry: Mapping[str, Any]
) -> Optional[str]:
"""
Send a ScanBaryCentre to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
@ -913,7 +1019,7 @@ Msg:\n{msg}'''
#######################################################################
# Augmentations
#######################################################################
ret = this.eddn.entry_augment_system_data(entry, entry['StarSystem'])
ret = this.eddn.entry_augment_system_data(entry, entry['StarSystem'], system_starpos)
if isinstance(ret, str):
return ret
@ -935,6 +1041,7 @@ Msg:\n{msg}'''
Send a NavRoute to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
@ -996,6 +1103,86 @@ Msg:\n{msg}'''
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def export_journal_approachsettlement(
self, cmdr: str, system_name: str, system_starpos: list, is_beta: bool, entry: MutableMapping[str, Any]
) -> Optional[str]:
"""
Send an ApproachSettlement to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_name: Name of current star system
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
# {
# "BodyID": 32,
# "BodyName": "Ix 5 a a",
# "Latitude": 17.090912,
# "Longitude": 160.236679,
# "MarketID": 3915738368,
# "Name": "Arnold Defence Base",
# "SystemAddress": 2381282543963,
# "event": "ApproachSettlement",
# "timestamp": "2021-10-14T12:37:54Z"
# }
#######################################################################
# Augmentations
#######################################################################
# In this case should add StarSystem and StarPos
ret = this.eddn.entry_augment_system_data(entry, system_name, system_starpos)
if isinstance(ret, str):
return ret
entry = ret
#######################################################################
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/approachsettlement/1{"/test" if is_beta else ""}',
'message': entry
}
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def export_journal_fssallbodiesfound(
self, cmdr: str, system_name: str, system_starpos: list, is_beta: bool, entry: MutableMapping[str, Any]
) -> Optional[str]:
"""
Send an FSSAllBodiesFound message to EDDN on the correct schema.
:param cmdr: the commander under which this upload is made
:param system_name: Name of current star system
:param system_starpos: Coordinates of current star system
:param is_beta: whether or not we are in beta mode
:param entry: the journal entry to send
"""
# {
# "Count": 3,
# "SystemAddress": 9466778822057,
# "SystemName": "LP 704-74",
# "event": "FSSAllBodiesFound",
# "timestamp": "2022-02-09T18:15:14Z"
# }
#######################################################################
# Augmentations
#######################################################################
# In this case should add StarSystem and StarPos
ret = this.eddn.entry_augment_system_data(entry, system_name, system_starpos)
if isinstance(ret, str):
return ret
entry = ret
#######################################################################
msg = {
'$schemaRef': f'https://eddn.edcd.io/schemas/fssallbodiesfound/1{"/test" if is_beta else ""}',
'message': entry
}
this.eddn.export_journal_entry(cmdr, entry, msg)
return None
def canonicalise(self, item: str) -> str:
"""
Canonicalise the given commodity name.
@ -1237,6 +1424,7 @@ def journal_entry( # noqa: C901, CCR001
return None
entry = new_data
event_name = entry['event'].lower()
this.on_foot = state['OnFoot']
@ -1246,8 +1434,8 @@ def journal_entry( # noqa: C901, CCR001
this.odyssey = entry['odyssey'] = state['Odyssey']
# Track location
if entry['event'] in ('Location', 'FSDJump', 'Docked', 'CarrierJump'):
if entry['event'] in ('Location', 'CarrierJump'):
if event_name in ('location', 'fsdjump', 'docked', 'carrierjump'):
if event_name in ('location', 'carrierjump'):
if entry.get('BodyType') == 'Planet':
this.body_name = entry.get('Body')
this.body_id = entry.get('BodyID')
@ -1255,7 +1443,7 @@ def journal_entry( # noqa: C901, CCR001
else:
this.body_name = None
elif entry['event'] == 'FSDJump':
elif event_name == 'fsdjump':
this.body_name = None
this.body_id = None
@ -1265,13 +1453,20 @@ def journal_entry( # noqa: C901, CCR001
elif this.systemaddress != entry.get('SystemAddress'):
this.coordinates = None # Docked event doesn't include coordinates
this.systemaddress = entry.get('SystemAddress') # type: ignore
if 'SystemAddress' not in entry:
logger.warning(f'"location" event without SystemAddress !!!:\n{entry}\n')
elif entry['event'] == 'ApproachBody':
# But we'll still *use* the value, because if a 'location' event doesn't
# have this we've still moved and now don't know where and MUST NOT
# continue to use any old value.
# Yes, explicitly state `None` here, so it's crystal clear.
this.systemaddress = entry.get('SystemAddress', None) # type: ignore
elif event_name == 'approachbody':
this.body_name = entry['Body']
this.body_id = entry.get('BodyID')
elif entry['event'] == 'LeaveBody':
elif event_name == 'leavebody':
# NB: **NOT** SupercruiseEntry, because we won't get a fresh
# ApproachBody if we don't leave Orbital Cruise and land again.
# *This* is triggered when you go above Orbital Cruise altitude.
@ -1279,7 +1474,7 @@ def journal_entry( # noqa: C901, CCR001
this.body_name = None
this.body_id = None
elif entry['event'] == 'Music':
elif event_name == 'music':
if entry['MusicTrack'] == 'MainMenu':
this.body_name = None
this.body_id = None
@ -1287,24 +1482,43 @@ def journal_entry( # noqa: C901, CCR001
# Events with their own EDDN schema
if config.get_int('output') & config.OUT_SYS_EDDN and not state['Captain']:
if entry['event'].lower() == 'fssdiscoveryscan':
return this.eddn.export_journal_fssdiscoveryscan(cmdr, system, is_beta, entry)
if entry['event'].lower() == 'navbeaconscan':
return this.eddn.export_journal_navbeaconscan(cmdr, system, is_beta, entry)
if event_name == 'fssdiscoveryscan':
return this.eddn.export_journal_fssdiscoveryscan(cmdr, system, state['StarPos'], is_beta, entry)
if entry['event'].lower() == 'codexentry':
return this.eddn.export_journal_codexentry(cmdr, is_beta, entry)
elif event_name == 'navbeaconscan':
return this.eddn.export_journal_navbeaconscan(cmdr, system, state['StarPos'], is_beta, entry)
if entry['event'].lower() == 'scanbarycentre':
return this.eddn.export_journal_scanbarycentre(cmdr, is_beta, entry)
elif event_name == 'codexentry':
return this.eddn.export_journal_codexentry(cmdr, state['StarPos'], is_beta, entry)
if entry['event'].lower() == 'navroute':
elif event_name == 'scanbarycentre':
return this.eddn.export_journal_scanbarycentre(cmdr, state['StarPos'], is_beta, entry)
elif event_name == 'navroute':
return this.eddn.export_journal_navroute(cmdr, is_beta, entry)
elif event_name == 'approachsettlement':
return this.eddn.export_journal_approachsettlement(
cmdr,
system,
state['StarPos'],
is_beta,
entry
)
elif event_name == 'fssallbodiesfound':
return this.eddn.export_journal_fssallbodiesfound(
cmdr,
system,
state['StarPos'],
is_beta,
entry
)
# Send journal schema events to EDDN, but not when on a crew
if (config.get_int('output') & config.OUT_SYS_EDDN and not state['Captain'] and
(entry['event'] in ('Location', 'FSDJump', 'Docked', 'Scan', 'SAASignalsFound', 'CarrierJump')) and
(event_name in ('location', 'fsdjump', 'docked', 'scan', 'saasignalsfound', 'carrierjump')) and
('StarPos' in entry or this.coordinates)):
# strip out properties disallowed by the schema
@ -1334,22 +1548,22 @@ def journal_entry( # noqa: C901, CCR001
]
# add planet to Docked event for planetary stations if known
if entry['event'] == 'Docked' and this.body_name:
if event_name == 'docked' and this.body_name:
entry['Body'] = this.body_name
entry['BodyType'] = 'Planet'
# add mandatory StarSystem, StarPos and SystemAddress properties to Scan events
if 'StarSystem' not in entry:
if not system:
logger.warning("system is None, can't add StarSystem")
return "system is None, can't add StarSystem"
logger.warning("system is falsey, can't add StarSystem")
return "system is falsey, can't add StarSystem"
entry['StarSystem'] = system
if 'StarPos' not in entry:
if not this.coordinates:
logger.warning("this.coordinates is None, can't add StarPos")
return "this.coordinates is None, can't add StarPos"
logger.warning("this.coordinates is falsey, can't add StarPos")
return "this.coordinates is falsey, can't add StarPos"
# Gazelle[TD] reported seeing a lagged Scan event with incorrect
# augmented StarPos: <https://github.com/EDCD/EDMarketConnector/issues/961>
@ -1361,8 +1575,8 @@ def journal_entry( # noqa: C901, CCR001
if 'SystemAddress' not in entry:
if not this.systemaddress:
logger.warning("this.systemaddress is None, can't add SystemAddress")
return "this.systemaddress is None, can't add SystemAddress"
logger.warning("this.systemaddress is falsey, can't add SystemAddress")
return "this.systemaddress is falsey, can't add SystemAddress"
entry['SystemAddress'] = this.systemaddress
@ -1378,7 +1592,7 @@ def journal_entry( # noqa: C901, CCR001
return str(e)
elif (config.get_int('output') & config.OUT_MKT_EDDN and not state['Captain'] and
entry['event'] in ('Market', 'Outfitting', 'Shipyard')):
event_name in ('market', 'outfitting', 'shipyard')):
# Market.json, Outfitting.json or Shipyard.json to process
try:
@ -1393,16 +1607,19 @@ def journal_entry( # noqa: C901, CCR001
path = pathlib.Path(journaldir) / f'{entry["event"]}.json'
with path.open('rb') as f:
entry = json.load(f)
entry['odyssey'] = this.odyssey
if entry['event'] == 'Market':
this.eddn.export_journal_commodities(cmdr, is_beta, entry)
# Don't assume we can definitely stomp entry & event_name here
entry_augment = json.load(f)
event_name_augment = entry_augment['event'].lower()
entry_augment['odyssey'] = this.odyssey
elif entry['event'] == 'Outfitting':
this.eddn.export_journal_outfitting(cmdr, is_beta, entry)
if event_name_augment == 'market':
this.eddn.export_journal_commodities(cmdr, is_beta, entry_augment)
elif entry['event'] == 'Shipyard':
this.eddn.export_journal_shipyard(cmdr, is_beta, entry)
elif event_name_augment == 'outfitting':
this.eddn.export_journal_outfitting(cmdr, is_beta, entry_augment)
elif event_name_augment == 'shipyard':
this.eddn.export_journal_shipyard(cmdr, is_beta, entry_augment)
except requests.exceptions.RequestException as e:
logger.debug(f'Failed exporting {entry["event"]}', exc_info=e)
@ -1518,10 +1735,12 @@ def dashboard_entry(cmdr: str, is_beta: bool, entry: Dict[str, Any]) -> None:
:param is_beta: Whether non-live game version was detected.
:param entry: The latest Status.json data.
"""
this.status_body_name = None
if 'BodyName' in entry:
this.status_body_name = entry['BodyName']
if not isinstance(entry['BodyName'], str):
logger.warning(f'BodyName was present but not a string! "{entry["BodyName"]}" ({type(entry["BodyName"])})')
else:
this.status_body_name = None
else:
this.status_body_name = entry['BodyName']
tracking_ui_update()

View File

@ -9,6 +9,28 @@
# 4) Ensure the EDSM API call(back) for setting the image at end of system
# text is always fired. i.e. CAPI cmdr_data() processing.
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# This is an EDMC 'core' plugin.
#
# All EDMC plugins are *dynamically* loaded at run-time.
#
# We build for Windows using `py2exe`.
#
# `py2exe` can't possibly know about anything in the dynamically loaded
# core plugins.
#
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import json
import threading
import tkinter as tk
@ -23,7 +45,7 @@ import killswitch
import myNotebook as nb # noqa: N813
import plug
from companion import CAPIData
from config import applongname, appversion, config, debug_senders
from config import applongname, appversion, config, debug_senders, user_agent
from edmc_data import DEBUG_WEBSERVER_HOST, DEBUG_WEBSERVER_PORT
from EDMCLogging import get_main_logger
from ttkHyperlinkLabel import HyperlinkLabel
@ -49,6 +71,7 @@ class This:
self.shutting_down = False # Plugin is shutting down.
self.session: requests.Session = requests.Session()
self.session.headers['User-Agent'] = user_agent
self.queue: Queue = Queue() # Items to be sent to EDSM by worker thread
self.discarded_events: Set[str] = [] # List discarded events from EDSM
self.lastlookup: requests.Response # Result of last system lookup

View File

@ -1,5 +1,27 @@
# EDShipyard ship export
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# This is an EDMC 'core' plugin.
#
# All EDMC plugins are *dynamically* loaded at run-time.
#
# We build for Windows using `py2exe`.
#
# `py2exe` can't possibly know about anything in the dynamically loaded
# core plugins.
#
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import base64
import gzip
import json

View File

@ -1,5 +1,27 @@
"""Inara Sync."""
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
#
# This is an EDMC 'core' plugin.
#
# All EDMC plugins are *dynamically* loaded at run-time.
#
# We build for Windows using `py2exe`.
#
# `py2exe` can't possibly know about anything in the dynamically loaded
# core plugins.
#
# Thus you **MUST** check if any imports you add in this file are only
# referenced in this file (or only in any other core plugin), and if so...
#
# YOU MUST ENSURE THAT PERTINENT ADJUSTMENTS ARE MADE IN `setup.py`
# SO AS TO ENSURE THE FILES ARE ACTUALLY PRESENT IN AN END-USER
# INSTALLATION ON WINDOWS.
#
#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $# ! $#
import json
import threading
import time
@ -33,7 +55,10 @@ if TYPE_CHECKING:
_TIMEOUT = 20
FAKE = ('CQC', 'Training', 'Destination') # Fake systems that shouldn't be sent to Inara
CREDIT_RATIO = 1.05 # Update credits if they change by 5% over the course of a session
# We only update Credits to Inara if the delta from the last sent value is
# greater than certain thresholds
CREDITS_DELTA_MIN_FRACTION = 0.05 # Fractional difference threshold
CREDITS_DELTA_MIN_ABSOLUTE = 10_000_000 # Absolute difference threshold
# These need to be defined above This
@ -76,7 +101,7 @@ class This:
self.suppress_docked = False # Skip initial Docked event if started docked
self.cargo: Optional[List[OrderedDictT[str, Any]]] = None
self.materials: Optional[List[OrderedDictT[str, Any]]] = None
self.lastcredits: int = 0 # Send credit update soon after Startup / new game
self.last_credits: int = 0 # Send credit update soon after Startup / new game
self.storedmodules: Optional[List[OrderedDictT[str, Any]]] = None
self.loadout: Optional[OrderedDictT[str, Any]] = None
self.fleet: Optional[List[OrderedDictT[str, Any]]] = None
@ -372,7 +397,7 @@ def journal_entry( # noqa: C901, CCR001
this.suppress_docked = False
this.cargo = None
this.materials = None
this.lastcredits = 0
this.last_credits = 0
this.storedmodules = None
this.loadout = None
this.fleet = None
@ -383,8 +408,8 @@ def journal_entry( # noqa: C901, CCR001
this.station_marketid = None
elif event_name in ('Resurrect', 'ShipyardBuy', 'ShipyardSell', 'SellShipOnRebuy'):
# Events that mean a significant change in credits so we should send credits after next "Update"
this.lastcredits = 0
# Events that mean a significant change in credits, so we should send credits after next "Update"
this.last_credits = 0
elif event_name in ('ShipyardNew', 'ShipyardSwap') or (event_name == 'Location' and entry['Docked']):
this.suppress_docked = True
@ -419,23 +444,13 @@ def journal_entry( # noqa: C901, CCR001
this.station_marketid = None
if config.get_int('inara_out') and not is_beta and not this.multicrew and credentials(cmdr):
current_creds = Credentials(this.cmdr, this.FID, str(credentials(this.cmdr)))
current_credentials = Credentials(this.cmdr, this.FID, str(credentials(this.cmdr)))
try:
# Dump starting state to Inara
if (this.newuser or event_name == 'StartUp' or (this.newsession and event_name == 'Cargo')):
this.newuser = False
this.newsession = False
# Send rank info to Inara on startup
new_add_event(
'setCommanderRankPilot',
entry['timestamp'],
[
{'rankName': k.lower(), 'rankValue': v[0], 'rankProgress': v[1] / 100.0}
for k, v in state['Rank'].items() if v is not None
]
)
# Don't send the API call with no values.
if state['Reputation']:
new_add_event(
@ -503,6 +518,19 @@ def journal_entry( # noqa: C901, CCR001
this.loadout = make_loadout(state)
new_add_event('setCommanderShipLoadout', entry['timestamp'], this.loadout)
# Trigger off the "only observed as being after Ranks" event so that
# we have both current Ranks *and* current Progress within them.
elif event_name == 'Progress':
# Send rank info to Inara on startup
new_add_event(
'setCommanderRankPilot',
entry['timestamp'],
[
{'rankName': k.lower(), 'rankValue': v[0], 'rankProgress': v[1] / 100.0}
for k, v in state['Rank'].items() if v is not None
]
)
# Promotions
elif event_name == 'Promotion':
for k, v in state['Rank'].items():
@ -737,17 +765,24 @@ def journal_entry( # noqa: C901, CCR001
logger.debug('Adding events', exc_info=e)
return str(e)
# Send credits and stats to Inara on startup only - otherwise may be out of date
# We want to utilise some Statistics data, so don't setCommanderCredits here
if event_name == 'LoadGame':
this.last_credits = state['Credits']
elif event_name == 'Statistics':
inara_data = {
'commanderCredits': state['Credits'],
'commanderLoan': state['Loan'],
}
if entry.get('Bank_Account') is not None:
if entry['Bank_Account'].get('Current_Wealth') is not None:
inara_data['commanderAssets'] = entry['Bank_Account']['Current_Wealth']
new_add_event(
'setCommanderCredits',
entry['timestamp'],
{'commanderCredits': state['Credits'], 'commanderLoan': state['Loan']}
inara_data
)
this.lastcredits = state['Credits']
elif event_name == 'Statistics':
new_add_event('setCommanderGameStatistics', entry['timestamp'], state['Statistics']) # may be out of date
# Selling / swapping ships
@ -837,7 +872,7 @@ def journal_entry( # noqa: C901, CCR001
if this.fleet != fleet:
this.fleet = fleet
this.filter_events(current_creds, lambda e: e.name != 'setCommanderShip')
this.filter_events(current_credentials, lambda e: e.name != 'setCommanderShip')
# this.events = [x for x in this.events if x['eventName'] != 'setCommanderShip'] # Remove any unsent
for ship in this.fleet:
@ -850,7 +885,7 @@ def journal_entry( # noqa: C901, CCR001
this.loadout = loadout
this.filter_events(
current_creds,
current_credentials,
lambda e: (
e.name != 'setCommanderShipLoadout'
or cast(dict, e.data)['shipGameID'] != cast(dict, this.loadout)['shipGameID'])
@ -891,7 +926,7 @@ def journal_entry( # noqa: C901, CCR001
# Only send on change
this.storedmodules = modules
# Remove any unsent
this.filter_events(current_creds, lambda e: e.name != 'setCommanderStorageModules')
this.filter_events(current_credentials, lambda e: e.name != 'setCommanderStorageModules')
# this.events = list(filter(lambda e: e['eventName'] != 'setCommanderStorageModules', this.events))
new_add_event('setCommanderStorageModules', entry['timestamp'], this.storedmodules)
@ -1220,7 +1255,7 @@ def journal_entry( # noqa: C901, CCR001
if event_name == 'CommunityGoal':
# Remove any unsent
this.filter_events(
current_creds, lambda e: e.name not in ('setCommunityGoal', 'setCommanderCommunityGoalProgress')
current_credentials, lambda e: e.name not in ('setCommunityGoal', 'setCommanderCommunityGoalProgress')
)
# this.events = list(filter(
@ -1349,23 +1384,8 @@ def cmdr_data(data: CAPIData, is_beta): # noqa: CCR001
this.station_link.update_idletasks()
if config.get_int('inara_out') and not is_beta and not this.multicrew and credentials(this.cmdr):
if not (CREDIT_RATIO > this.lastcredits / data['commander']['credits'] > 1/CREDIT_RATIO):
this.filter_events(
Credentials(this.cmdr, this.FID, str(credentials(this.cmdr))),
lambda e: e.name != 'setCommanderCredits'
)
# this.events = [x for x in this.events if x['eventName'] != 'setCommanderCredits'] # Remove any unsent
new_add_event(
'setCommanderCredits',
data['timestamp'],
{
'commanderCredits': data['commander']['credits'],
'commanderLoan': data['commander'].get('debt', 0),
}
)
this.lastcredits = int(data['commander']['credits'])
# Only here to ensure the conditional is correct for future additions
pass
def make_loadout(state: Dict[str, Any]) -> OrderedDictT[str, Any]: # noqa: CCR001
@ -1567,6 +1587,7 @@ def send_data(url: str, data: Mapping[str, Any]) -> bool: # noqa: CCR001
:param data: the data to POST
:return: success state
"""
# NB: As of 2022-01-25 Artie has stated the Inara API does *not* support compression
r = this.session.post(url, data=json.dumps(data, separators=(',', ':')), timeout=_TIMEOUT)
r.raise_for_status()
reply = r.json()

View File

@ -3,10 +3,10 @@
import contextlib
import logging
import sys
import tkinter as tk
import webbrowser
from os.path import expanduser, expandvars, join, normpath
from sys import platform
from tkinter import colorchooser as tkColorChooser # type: ignore # noqa: N812
from tkinter import ttk
from types import TracebackType
@ -154,7 +154,7 @@ class AutoInc(contextlib.AbstractContextManager):
return None
if platform == 'darwin':
if sys.platform == 'darwin':
import objc # type: ignore
from Foundation import NSFileManager # type: ignore
try:
@ -179,7 +179,7 @@ if platform == 'darwin':
was_accessible_at_launch = AXIsProcessTrusted() # type: ignore
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
import winreg
from ctypes.wintypes import HINSTANCE, HWND, LPARAM, LPCWSTR, LPVOID, LPWSTR, MAX_PATH, POINT, RECT, SIZE, UINT
@ -246,7 +246,7 @@ class PreferencesDialog(tk.Toplevel):
self.parent = parent
self.callback = callback
if platform == 'darwin':
if sys.platform == 'darwin':
# LANG: File > Preferences menu entry for macOS
self.title(_('Preferences'))
@ -258,15 +258,15 @@ class PreferencesDialog(tk.Toplevel):
self.transient(parent)
# position over parent
if platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if sys.platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
# TODO this is fixed supposedly.
self.geometry(f'+{parent.winfo_rootx()}+{parent.winfo_rooty()}')
# remove decoration
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
elif sys.platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')
@ -294,7 +294,7 @@ class PreferencesDialog(tk.Toplevel):
self.__setup_appearance_tab(notebook)
self.__setup_plugin_tab(notebook)
if platform == 'darwin':
if sys.platform == 'darwin':
self.protocol("WM_DELETE_WINDOW", self.apply) # close button applies changes
else:
@ -322,7 +322,7 @@ class PreferencesDialog(tk.Toplevel):
self.grab_set()
# Ensure fully on-screen
if platform == 'win32' and CalculatePopupWindowPosition:
if sys.platform == 'win32' and CalculatePopupWindowPosition:
position = RECT()
GetWindowRect(GetParent(self.winfo_id()), position)
if CalculatePopupWindowPosition(
@ -396,7 +396,7 @@ class PreferencesDialog(tk.Toplevel):
self.outdir_entry = nb.Entry(output_frame, takefocus=False)
self.outdir_entry.grid(columnspan=2, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row.get())
if platform == 'darwin':
if sys.platform == 'darwin':
text = (_('Change...')) # LANG: macOS Preferences - files location selection button
else:
@ -446,7 +446,7 @@ class PreferencesDialog(tk.Toplevel):
self.logdir_entry.grid(columnspan=4, padx=self.PADX, pady=(0, self.PADY), sticky=tk.EW, row=row.get())
if platform == 'darwin':
if sys.platform == 'darwin':
text = (_('Change...')) # LANG: macOS Preferences - files location selection button
else:
@ -470,7 +470,7 @@ class PreferencesDialog(tk.Toplevel):
state=tk.NORMAL if config.get_str('journaldir') else tk.DISABLED
).grid(column=2, pady=self.PADY, sticky=tk.EW, row=row.get())
if platform in ('darwin', 'win32'):
if sys.platform in ('darwin', 'win32'):
ttk.Separator(config_frame, orient=tk.HORIZONTAL).grid(
columnspan=4, padx=self.PADX, pady=self.PADY*4, sticky=tk.EW, row=row.get()
)
@ -482,11 +482,11 @@ class PreferencesDialog(tk.Toplevel):
nb.Label(
config_frame,
text=_('Keyboard shortcut') if # LANG: Hotkey/Shortcut settings prompt on OSX
platform == 'darwin' else
sys.platform == 'darwin' else
_('Hotkey') # LANG: Hotkey/Shortcut settings prompt on Windows
).grid(padx=self.PADX, sticky=tk.W, row=row.get())
if platform == 'darwin' and not was_accessible_at_launch:
if sys.platform == 'darwin' and not was_accessible_at_launch:
if AXIsProcessTrusted():
# Shortcut settings prompt on OSX
nb.Label(
@ -511,7 +511,8 @@ class PreferencesDialog(tk.Toplevel):
)
else:
self.hotkey_text = nb.Entry(config_frame, width=(20 if platform == 'darwin' else 30), justify=tk.CENTER)
self.hotkey_text = nb.Entry(config_frame, width=(
20 if sys.platform == 'darwin' else 30), justify=tk.CENTER)
self.hotkey_text.insert(
0,
# No hotkey/shortcut currently defined
@ -741,7 +742,7 @@ class PreferencesDialog(tk.Toplevel):
appearance_frame, text=_('Dark'), variable=self.theme, value=1, command=self.themevarchanged
).grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get())
if platform == 'win32':
if sys.platform == 'win32':
nb.Radiobutton(
appearance_frame,
# LANG: Label for 'Transparent' theme radio button
@ -870,7 +871,7 @@ class PreferencesDialog(tk.Toplevel):
)
self.ontop_button.grid(columnspan=3, padx=self.BUTTONX, sticky=tk.W, row=row.get()) # Appearance setting
if platform == 'win32':
if sys.platform == 'win32':
nb.Checkbutton(
appearance_frame,
# LANG: Appearance option for Windows "minimize to system tray"
@ -997,7 +998,7 @@ class PreferencesDialog(tk.Toplevel):
def tabchanged(self, event: tk.Event) -> None:
"""Handle preferences active tab changing."""
self.outvarchanged()
if platform == 'darwin':
if sys.platform == 'darwin':
# Hack to recompute size so that buttons show up under Mojave
notebook = event.widget
frame = self.nametowidget(notebook.winfo_parent())
@ -1027,9 +1028,8 @@ class PreferencesDialog(tk.Toplevel):
# If encoding isn't UTF-8 we can't use the tkinter dialog
current_locale = locale.getlocale(locale.LC_CTYPE)
from sys import platform as sys_platform
directory = None
if sys_platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'):
if sys.platform == 'win32' and current_locale[1] not in ('utf8', 'UTF8', 'utf-8', 'UTF-8'):
def browsecallback(hwnd, uMsg, lParam, lpData): # noqa: N803 # Windows API convention
# set initial folder
if uMsg == BFFM_INITIALIZED and lpData:
@ -1075,7 +1075,7 @@ class PreferencesDialog(tk.Toplevel):
# TODO: This is awful.
entryfield['state'] = tk.NORMAL # must be writable to update
entryfield.delete(0, tk.END)
if platform == 'win32':
if sys.platform == 'win32':
start = len(config.home.split('\\')) if pathvar.get().lower().startswith(config.home.lower()) else 0
display = []
components = normpath(pathvar.get()).split('\\')
@ -1096,7 +1096,7 @@ class PreferencesDialog(tk.Toplevel):
entryfield.insert(0, '\\'.join(display))
# None if path doesn't exist
elif platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()):
elif sys.platform == 'darwin' and NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get()):
if pathvar.get().startswith(config.home):
display = ['~'] + NSFileManager.defaultManager().componentsToDisplayForPath_(pathvar.get())[
len(NSFileManager.defaultManager().componentsToDisplayForPath_(config.home)):
@ -1236,7 +1236,7 @@ class PreferencesDialog(tk.Toplevel):
else:
config.set('journaldir', logdir)
if platform in ('darwin', 'win32'):
if sys.platform in ('darwin', 'win32'):
config.set('hotkey_code', self.hotkey_code)
config.set('hotkey_mods', self.hotkey_mods)
config.set('hotkey_always', int(not self.hotkey_only.get()))
@ -1282,7 +1282,7 @@ class PreferencesDialog(tk.Toplevel):
self.parent.wm_attributes('-topmost', 1 if config.get_int('always_ontop') else 0)
self.destroy()
if platform == 'darwin':
if sys.platform == 'darwin':
def enableshortcuts(self) -> None:
"""Set up macOS preferences shortcut."""
self.apply()

View File

@ -3,10 +3,13 @@ max_line_length = 120
[tool.isort]
multi_line_output = 5
line_length = 119
line_length = 119
[tool.pytest.ini_options]
testpaths = ["tests"] # Search for tests in tests/
[tool.coverage.run]
omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories
omit = ["venv/*"] # when running pytest --cov, dont report coverage in venv directories
[tool.pyright]
# pythonPlatform = 'Darwin'

View File

@ -2,11 +2,16 @@
# Using legacy 'setup.py install' for flake8-annotations-coverage, since package 'wheel' is not installed.
wheel
# We can't rely on just picking this up from either the base (not venv),
# or venv-init-time version. Specify here so that dependabot will prod us
# about new versions.
setuptools==60.8.2
# Static analysis tools
flake8==4.0.1
flake8-annotations-coverage==0.0.5
flake8-cognitive-complexity==0.1.0
flake8-comprehensions==3.7.0
flake8-comprehensions==3.8.0
flake8-docstrings==1.6.0
isort==5.10.1
flake8-isort==4.1.1
@ -15,27 +20,31 @@ flake8-noqa==1.2.1
flake8-polyfill==1.0.2
flake8-use-fstring==1.3
mypy==0.910
mypy==0.931
pep8-naming==0.12.1
safety==1.10.3
types-requests==2.26.0
types-requests==2.27.9
# Code formatting tools
autopep8==1.6.0
# HTML changelogs
grip==4.5.2
grip==4.6.0
# Packaging
# Used to put together a WiX configuration from template/auto-gen
lxml==4.7.1
# We only need py2exe on windows.
py2exe==0.10.4.1; sys_platform == 'win32'
# Pre-release version addressing semantic_version 2.9.0+ issues:
# <https://github.com/py2exe/py2exe/issues/126>
py2exe==0.11.1.0; sys_platform == 'win32'
# Testing
pytest==6.2.5
pytest==7.0.0
pytest-cov==3.0.0 # Pytest code coverage support
coverage[toml]==6.1.2 # pytest-cov dep. This is here to ensure that it includes TOML support for pyproject.toml configs
coverage[toml]==6.3.1 # pytest-cov dep. This is here to ensure that it includes TOML support for pyproject.toml configs
# For manipulating folder permissions and the like.
pywin32==302; sys_platform == 'win32'
pywin32==303; sys_platform == 'win32'
# All of the normal requirements

View File

@ -1,11 +1,11 @@
certifi==2021.10.8
requests==2.26.0
requests==2.27.1
watchdog==2.1.6
# Commented out because this doesn't package well with py2exe
infi.systray==0.1.12; sys_platform == 'win32'
# argh==0.26.2 watchdog dep
# pyyaml==5.3.1 watchdog dep
semantic-version==2.8.5
semantic-version==2.9.0
# Base requirement for MacOS
pyobjc; sys_platform == 'darwin'

129
setup.py
View File

@ -9,6 +9,7 @@ Script to build to .exe and .msi package.
import codecs
import os
import pathlib
import platform
import re
import shutil
@ -18,13 +19,15 @@ from os.path import exists, isdir, join
from tempfile import gettempdir
from typing import Any, Generator, Set
from lxml import etree
from config import (
appcmdname, applongname, appname, appversion, appversion_nobuild, copyright, git_shorthash_from_head, update_feed,
update_interval
)
from constants import GITVERSION_FILE
if sys.version_info[0:2] != (3, 9):
if sys.version_info[0:2] != (3, 10):
raise AssertionError(f'Unexpected python version {sys.version}')
###########################################################################
@ -141,12 +144,16 @@ if sys.platform == 'darwin':
('plugins', x) for x in PLUGINS
],
'resources': [
'commodity.csv',
'rare_commodity.csv',
'.gitversion', # Contains git short hash
'ChangeLog.md',
'snd_good.wav',
'snd_bad.wav',
'modules.p',
'ships.p',
('FDevIDs', [
join('FDevIDs', 'commodity.csv'),
join('FDevIDs', 'rare_commodity.csv'),
]),
],
'site_packages': False,
'plist': {
@ -184,7 +191,10 @@ elif sys.platform == 'win32':
'dist_dir': dist_dir,
'optimize': 2,
'packages': [
'asyncio', # No longer auto as of py3.10+py2exe 0.11
'multiprocessing', # No longer auto as of py3.10+py2exe 0.11
'sqlite3', # Included for plugins
'util', # 2022-02-01 only imported in plugins/eddn.py
],
'includes': [
'dataclasses',
@ -209,9 +219,7 @@ elif sys.platform == 'win32':
'WinSparkle.dll',
'WinSparkle.pdb', # For debugging - don't include in package
'EUROCAPS.TTF',
'Changelog.md',
'commodity.csv',
'rare_commodity.csv',
'ChangeLog.md',
'snd_good.wav',
'snd_bad.wav',
'modules.p',
@ -223,6 +231,10 @@ elif sys.platform == 'win32':
'EDMarketConnector - reset-ui.bat',
]),
('L10n', [join('L10n', x) for x in os.listdir('L10n') if x.endswith('.strings')]),
('FDevIDs', [
join('FDevIDs', 'commodity.csv'),
join('FDevIDs', 'rare_commodity.csv'),
]),
('plugins', PLUGINS),
]
@ -283,13 +295,110 @@ if sys.platform == 'darwin':
os.system(f'cd {dist_dir}; ditto -ck --keepParent --sequesterRsrc {appname}.app ../{package_filename}; cd ..')
elif sys.platform == 'win32':
os.system(rf'"{WIXPATH}\candle.exe" -out {dist_dir}\ {appname}.wxs')
template_file = pathlib.Path('wix/template.wxs')
components_file = pathlib.Path('wix/components.wxs')
final_wxs_file = pathlib.Path('EDMarketConnector.wxs')
if not exists(f'{dist_dir}/{appname}.wixobj'):
raise AssertionError(f'No {dist_dir}/{appname}.wixobj: candle.exe failed?')
# Use heat.exe to generate the Component for all files inside dist.win32
os.system(rf'"{WIXPATH}\heat.exe" dir {dist_dir}\ -ag -sfrag -srid -suid -out {components_file}')
component_tree = etree.parse(str(components_file))
# 1. Change the element:
#
# <Directory Id="dist.win32" Name="dist.win32">
#
# to:
#
# <Directory Id="INSTALLDIR" Name="$(var.PRODUCTNAME)">
directory_win32 = component_tree.find('.//{*}Directory[@Id="dist.win32"][@Name="dist.win32"]')
if directory_win32 is None:
raise ValueError(f'{components_file}: Expected Directory with Id="dist.win32"')
directory_win32.set('Id', 'INSTALLDIR')
directory_win32.set('Name', '$(var.PRODUCTNAME)')
# 2. Change:
#
# <Component Id="EDMarketConnector.exe" Guid="*">
# <File Id="EDMarketConnector.exe" KeyPath="yes" Source="SourceDir\EDMarketConnector.exe" />
# </Component>
#
# to:
#
# <Component Id="MainExecutable" Guid="{D33BB66E-9664-4AB6-A044-3004B50A09B0}">
# <File Id="EDMarketConnector.exe" KeyPath="yes" Source="SourceDir\EDMarketConnector.exe" />
# <Shortcut Id="MainExeShortcut" Directory="ProgramMenuFolder" Name="$(var.PRODUCTLONGNAME)"
# Description="Downloads station data from Elite: Dangerous" WorkingDirectory="INSTALLDIR"
# Icon="EDMarketConnector.exe" IconIndex="0" Advertise="yes" />
# </Component>
main_executable = directory_win32.find('.//{*}Component[@Id="EDMarketConnector.exe"]')
if main_executable is None:
raise ValueError(f'{components_file}: Expected Component with Id="EDMarketConnector.exe"')
main_executable.set('Id', 'MainExecutable')
main_executable.set('Guid', '{D33BB66E-9664-4AB6-A044-3004B50A09B0}')
shortcut = etree.SubElement(
main_executable,
'Shortcut',
nsmap=main_executable.nsmap,
attrib={
'Id': 'MainExeShortcut',
'Directory': 'ProgramMenuFolder',
'Name': '$(var.PRODUCTLONGNAME)',
'Description': 'Downloads station data from Elite: Dangerous',
'WorkingDirectory': 'INSTALLDIR',
'Icon': 'EDMarketConnector.exe',
'IconIndex': '0',
'Advertise': 'yes'
}
)
# Now insert the appropriate parts as a child of the ProgramFilesFolder part
# of the template.
template_tree = etree.parse(str(template_file))
program_files_folder = template_tree.find('.//{*}Directory[@Id="ProgramFilesFolder"]')
if program_files_folder is None:
raise ValueError(f'{template_file}: Expected Directory with Id="ProgramFilesFolder"')
program_files_folder.insert(0, directory_win32)
# Append the Feature/ComponentRef listing to match
feature = template_tree.find('.//{*}Feature[@Id="Complete"][@Level="1"]')
if feature is None:
raise ValueError(f'{template_file}: Expected Feature element with Id="Complete" Level="1"')
# This isn't part of the components
feature.append(
etree.Element(
'ComponentRef',
attrib={
'Id': 'RegistryEntries'
},
nsmap=directory_win32.nsmap
)
)
for c in directory_win32.findall('.//{*}Component'):
feature.append(
etree.Element(
'ComponentRef',
attrib={
'Id': c.get('Id')
},
nsmap=directory_win32.nsmap
)
)
# Insert what we now have into the template and write it out
template_tree.write(
str(final_wxs_file), encoding='utf-8',
pretty_print=True,
xml_declaration=True
)
os.system(rf'"{WIXPATH}\candle.exe" {appname}.wxs')
if not exists(f'{appname}.wixobj'):
raise AssertionError(f'No {appname}.wixobj: candle.exe failed?')
package_filename = f'{appname}_win_{appversion_nobuild()}.msi'
os.system(rf'"{WIXPATH}\light.exe" -sacl -spdb -sw1076 {dist_dir}\{appname}.wixobj -out {package_filename}')
os.system(rf'"{WIXPATH}\light.exe" -b {dist_dir}\ -sacl -spdb -sw1076 {appname}.wixobj -out {package_filename}')
if not exists(package_filename):
raise AssertionError(f'light.exe failed, no {package_filename}')

View File

@ -1,9 +1,9 @@
"""CMDR Status information."""
import csv
import json
import sys
import tkinter
import tkinter as tk
from sys import platform
from tkinter import ttk
from typing import TYPE_CHECKING, Any, AnyStr, Callable, Dict, List, NamedTuple, Optional, Sequence, cast
@ -20,11 +20,9 @@ logger = EDMCLogging.get_main_logger()
if TYPE_CHECKING:
def _(x: str) -> str: ...
if platform == 'win32':
if sys.platform == 'win32':
import ctypes
from ctypes.wintypes import HWND, POINT, RECT, SIZE, UINT
if TYPE_CHECKING:
import ctypes.windll # type: ignore # Fake this into existing, its really a magic dll thing
try:
CalculatePopupWindowPosition = ctypes.windll.user32.CalculatePopupWindowPosition
@ -40,6 +38,13 @@ if platform == 'win32':
CalculatePopupWindowPosition = None # type: ignore
CR_LINES_START = 1
CR_LINES_END = 3
RANK_LINES_START = 3
RANK_LINES_END = 9
POWERPLAY_LINES_START = 9
def status(data: Dict[str, Any]) -> List[List[str]]:
"""
Get the current status of the cmdr referred to by data.
@ -54,20 +59,33 @@ def status(data: Dict[str, Any]) -> List[List[str]]:
[_('Loan'), str(data['commander'].get('debt', 0))], # LANG: Cmdr stats
]
_ELITE_RANKS = [ # noqa: N806 # Its a constant, just needs to be updated at runtime
_('Elite'), # LANG: Top rank
_('Elite I'), # LANG: Top rank +1
_('Elite II'), # LANG: Top rank +2
_('Elite III'), # LANG: Top rank +3
_('Elite IV'), # LANG: Top rank +4
_('Elite V'), # LANG: Top rank +5
]
RANKS = [ # noqa: N806 # Its a constant, just needs to be updated at runtime
# in output order
(_('Combat'), 'combat'), # LANG: Ranking
(_('Trade'), 'trade'), # LANG: Ranking
(_('Explorer'), 'explore'), # LANG: Ranking
(_('CQC'), 'cqc'), # LANG: Ranking
(_('Federation'), 'federation'), # LANG: Ranking
(_('Empire'), 'empire'), # LANG: Ranking
(_('Powerplay'), 'power'), # LANG: Ranking
# ??? , 'crime'), # LANG: Ranking
# ??? , 'service'), # LANG: Ranking
# Names we show people, vs internal names
(_('Combat'), 'combat'), # LANG: Ranking
(_('Trade'), 'trade'), # LANG: Ranking
(_('Explorer'), 'explore'), # LANG: Ranking
(_('Mercenary'), 'soldier'), # LANG: Ranking
(_('Exobiologist'), 'exobiologist'), # LANG: Ranking
(_('CQC'), 'cqc'), # LANG: Ranking
(_('Federation'), 'federation'), # LANG: Ranking
(_('Empire'), 'empire'), # LANG: Ranking
(_('Powerplay'), 'power'), # LANG: Ranking
# ??? , 'crime'), # LANG: Ranking
# ??? , 'service'), # LANG: Ranking
]
RANK_NAMES = { # noqa: N806 # Its a constant, just needs to be updated at runtime
# These names are the fdev side name (but lower()ed)
# http://elite-dangerous.wikia.com/wiki/Pilots_Federation#Ranks
'combat': [
_('Harmless'), # LANG: Combat rank
@ -78,8 +96,7 @@ def status(data: Dict[str, Any]) -> List[List[str]]:
_('Master'), # LANG: Combat rank
_('Dangerous'), # LANG: Combat rank
_('Deadly'), # LANG: Combat rank
_('Elite'), # LANG: Top rank
],
] + _ELITE_RANKS,
'trade': [
_('Penniless'), # LANG: Trade rank
_('Mostly Penniless'), # LANG: Trade rank
@ -89,8 +106,7 @@ def status(data: Dict[str, Any]) -> List[List[str]]:
_('Broker'), # LANG: Trade rank
_('Entrepreneur'), # LANG: Trade rank
_('Tycoon'), # LANG: Trade rank
_('Elite') # LANG: Top rank
],
] + _ELITE_RANKS,
'explore': [
_('Aimless'), # LANG: Explorer rank
_('Mostly Aimless'), # LANG: Explorer rank
@ -100,8 +116,28 @@ def status(data: Dict[str, Any]) -> List[List[str]]:
_('Pathfinder'), # LANG: Explorer rank
_('Ranger'), # LANG: Explorer rank
_('Pioneer'), # LANG: Explorer rank
_('Elite') # LANG: Top rank
],
] + _ELITE_RANKS,
'soldier': [
_('Defenceless'), # LANG: Mercenary rank
_('Mostly Defenceless'), # LANG: Mercenary rank
_('Rookie'), # LANG: Mercenary rank
_('Soldier'), # LANG: Mercenary rank
_('Gunslinger'), # LANG: Mercenary rank
_('Warrior'), # LANG: Mercenary rank
_('Gunslinger'), # LANG: Mercenary rank
_('Deadeye'), # LANG: Mercenary rank
] + _ELITE_RANKS,
'exobiologist': [
_('Directionless'), # LANG: Exobiologist rank
_('Mostly Directionless'), # LANG: Exobiologist rank
_('Compiler'), # LANG: Exobiologist rank
_('Collector'), # LANG: Exobiologist rank
_('Cataloguer'), # LANG: Exobiologist rank
_('Taxonomist'), # LANG: Exobiologist rank
_('Ecologist'), # LANG: Exobiologist rank
_('Geneticist'), # LANG: Exobiologist rank
] + _ELITE_RANKS,
'cqc': [
_('Helpless'), # LANG: CQC rank
_('Mostly Helpless'), # LANG: CQC rank
@ -111,8 +147,7 @@ def status(data: Dict[str, Any]) -> List[List[str]]:
_('Champion'), # LANG: CQC rank
_('Hero'), # LANG: CQC rank
_('Gladiator'), # LANG: CQC rank
_('Elite') # LANG: Top rank
],
] + _ELITE_RANKS,
# http://elite-dangerous.wikia.com/wiki/Federation#Ranks
'federation': [
@ -335,15 +370,15 @@ class StatsResults(tk.Toplevel):
self.transient(parent)
# position over parent
if platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
if sys.platform != 'darwin' or parent.winfo_rooty() > 0: # http://core.tcl.tk/tk/tktview/c84f660833546b1b84e7
self.geometry(f"+{parent.winfo_rootx()}+{parent.winfo_rooty()}")
# remove decoration
self.resizable(tk.FALSE, tk.FALSE)
if platform == 'win32':
if sys.platform == 'win32':
self.attributes('-toolwindow', tk.TRUE)
elif platform == 'darwin':
elif sys.platform == 'darwin':
# http://wiki.tcl.tk/13428
parent.call('tk::unsupported::MacWindowStyle', 'style', self, 'utility')
@ -353,11 +388,16 @@ class StatsResults(tk.Toplevel):
notebook = nb.Notebook(frame)
page = self.addpage(notebook)
for thing in stats[1:3]:
for thing in stats[CR_LINES_START:CR_LINES_END]:
# assumes things two and three are money
self.addpagerow(page, [thing[0], self.credits(int(thing[1]))], with_copy=True)
for thing in stats[3:]:
self.addpagespacer(page)
for thing in stats[RANK_LINES_START:RANK_LINES_END]:
self.addpagerow(page, thing, with_copy=True)
self.addpagespacer(page)
for thing in stats[POWERPLAY_LINES_START:]:
self.addpagerow(page, thing, with_copy=True)
ttk.Frame(page).grid(pady=5) # bottom spacer
@ -379,7 +419,7 @@ class StatsResults(tk.Toplevel):
ttk.Frame(page).grid(pady=5) # bottom spacer
notebook.add(page, text=_('Ships')) # LANG: Status dialog title
if platform != 'darwin':
if sys.platform != 'darwin':
buttonframe = ttk.Frame(frame)
buttonframe.grid(padx=10, pady=(0, 10), sticky=tk.NSEW) # type: ignore # the tuple is supported
buttonframe.columnconfigure(0, weight=1)
@ -391,7 +431,7 @@ class StatsResults(tk.Toplevel):
self.grab_set()
# Ensure fully on-screen
if platform == 'win32' and CalculatePopupWindowPosition:
if sys.platform == 'win32' and CalculatePopupWindowPosition:
position = RECT()
GetWindowRect(GetParent(self.winfo_id()), position)
if CalculatePopupWindowPosition(

View File

@ -4,7 +4,6 @@ import warnings
from configparser import NoOptionError
from os import getenv, makedirs, mkdir, pardir
from os.path import dirname, expanduser, isdir, join, normpath
from sys import platform
from typing import TYPE_CHECKING, Optional, Union
from config import applongname, appname, update_interval
@ -12,13 +11,13 @@ from EDMCLogging import get_main_logger
logger = get_main_logger()
if platform == 'darwin':
if sys.platform == 'darwin':
from Foundation import ( # type: ignore
NSApplicationSupportDirectory, NSBundle, NSDocumentDirectory, NSSearchPathForDirectoriesInDomains,
NSUserDefaults, NSUserDomainMask
)
elif platform == 'win32':
elif sys.platform == 'win32':
import ctypes
import uuid
from ctypes.wintypes import DWORD, HANDLE, HKEY, LONG, LPCVOID, LPCWSTR
@ -90,7 +89,7 @@ elif platform == 'win32':
CoTaskMemFree(buf) # and free original
return retval
elif platform == 'linux':
elif sys.platform == 'linux':
import codecs
from configparser import RawConfigParser
@ -114,7 +113,7 @@ class OldConfig():
OUT_SYS_EDDN = 2048
OUT_SYS_DELAY = 4096
if platform == 'darwin': # noqa: C901 # It's gating *all* the functions
if sys.platform == 'darwin': # noqa: C901 # It's gating *all* the functions
def __init__(self):
self.app_dir = join(
@ -199,7 +198,7 @@ class OldConfig():
self.save()
self.defaults = None
elif platform == 'win32':
elif sys.platform == 'win32':
def __init__(self):
self.app_dir = join(known_folder_path(FOLDERID_LocalAppData), appname) # type: ignore # Not going to change
@ -362,7 +361,7 @@ class OldConfig():
RegCloseKey(self.hkey)
self.hkey = None
elif platform == 'linux':
elif sys.platform == 'linux':
SECTION = 'config'
def __init__(self):

View File

@ -1,4 +1,12 @@
"""Test the config system."""
"""
Test the config system.
Note: These tests to arbitrary reads and writes to an existing config, including
key deletions. Said modifications are to keys that are generated internally.
Most of these tests are parity tests with the "old" config, and likely one day can be
entirely removed.
"""
from __future__ import annotations
import contextlib
@ -19,7 +27,7 @@ print(sys.path)
from _old_config import old_config # noqa: E402
from config import LinuxConfig, config # noqa: E402
from config import config # noqa: E402
def _fuzz_list(length: int) -> List[str]:
@ -77,6 +85,11 @@ class TestNewConfig:
def __update_linuxconfig(self) -> None:
"""On linux config uses ConfigParser, which doesn't update from disk changes. Force the update here."""
if sys.platform != 'linux':
return
from config.linux import LinuxConfig
if isinstance(config, LinuxConfig) and config.config is not None:
config.config.read(config.filename)
@ -163,6 +176,10 @@ class TestOldNewConfig:
def __update_linuxconfig(self) -> None:
"""On linux config uses ConfigParser, which doesn't update from disk changes. Force the update here."""
if sys.platform != 'linux':
return
from config.linux import LinuxConfig
if isinstance(config, LinuxConfig) and config.config is not None:
config.config.read(config.filename)

206
theme.py
View File

@ -6,9 +6,9 @@
#
import os
import sys
import tkinter as tk
from os.path import join
from sys import platform
from tkinter import font as tkFont
from tkinter import ttk
@ -18,20 +18,20 @@ from ttkHyperlinkLabel import HyperlinkLabel
if __debug__:
from traceback import print_exc
if platform == "linux":
if sys.platform == "linux":
from ctypes import POINTER, Structure, byref, c_char_p, c_int, c_long, c_uint, c_ulong, c_void_p, cdll
if platform == 'win32':
if sys.platform == 'win32':
import ctypes
from ctypes.wintypes import DWORD, LPCVOID, LPCWSTR
AddFontResourceEx = ctypes.windll.gdi32.AddFontResourceExW
AddFontResourceEx.restypes = [LPCWSTR, DWORD, LPCVOID]
FR_PRIVATE = 0x10
FR_PRIVATE = 0x10
FR_NOT_ENUM = 0x20
AddFontResourceEx(join(config.respath, u'EUROCAPS.TTF'), FR_PRIVATE, 0)
elif platform == 'linux':
elif sys.platform == 'linux':
# pyright: reportUnboundVariable=false
XID = c_ulong # from X.h: typedef unsigned long XID
Window = XID
@ -40,7 +40,7 @@ elif platform == 'linux':
PropModeReplace = 0
PropModePrepend = 1
PropModeAppend = 2
PropModeAppend = 2
# From xprops.h
MWM_HINTS_FUNCTIONS = 1 << 0
@ -69,16 +69,17 @@ elif platform == 'linux':
('input_mode', c_long),
('status', c_ulong),
]
# workaround for https://github.com/EDCD/EDMarketConnector/issues/568
if not os.getenv("EDMC_NO_UI") :
if not os.getenv("EDMC_NO_UI"):
try:
xlib = cdll.LoadLibrary('libX11.so.6')
XInternAtom = xlib.XInternAtom
XInternAtom.argtypes = [POINTER(Display), c_char_p, c_int]
XInternAtom.restype = Atom
XChangeProperty = xlib.XChangeProperty
XChangeProperty.argtypes = [POINTER(Display), Window, Atom, Atom, c_int, c_int, POINTER(MotifWmHints), c_int]
XChangeProperty.argtypes = [POINTER(Display), Window, Atom, Atom, c_int,
c_int, POINTER(MotifWmHints), c_int]
XChangeProperty.restype = c_int
XFlush = xlib.XFlush
XFlush.argtypes = [POINTER(Display)]
@ -87,29 +88,31 @@ elif platform == 'linux':
XOpenDisplay.argtypes = [c_char_p]
XOpenDisplay.restype = POINTER(Display)
XQueryTree = xlib.XQueryTree
XQueryTree.argtypes = [POINTER(Display), Window, POINTER(Window), POINTER(Window), POINTER(Window), POINTER(c_uint)]
XQueryTree.argtypes = [POINTER(Display), Window, POINTER(
Window), POINTER(Window), POINTER(Window), POINTER(c_uint)]
XQueryTree.restype = c_int
dpy = xlib.XOpenDisplay(None)
if not dpy:
raise Exception("Can't find your display, can't continue")
motif_wm_hints_property = XInternAtom(dpy, b'_MOTIF_WM_HINTS', False)
motif_wm_hints_normal = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE,
0, 0)
motif_wm_hints_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
0, 0, 0)
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
MWM_DECOR_BORDER | MWM_DECOR_RESIZEH | MWM_DECOR_TITLE | MWM_DECOR_MENU | MWM_DECOR_MINIMIZE,
0, 0)
motif_wm_hints_dark = MotifWmHints(MWM_HINTS_FUNCTIONS | MWM_HINTS_DECORATIONS,
MWM_FUNC_RESIZE | MWM_FUNC_MOVE | MWM_FUNC_MINIMIZE | MWM_FUNC_CLOSE,
0, 0, 0)
except:
if __debug__: print_exc()
if __debug__:
print_exc()
dpy = None
class _Theme(object):
def __init__(self):
self.active = None # Starts out with no theme
self.active = None # Starts out with no theme
self.minwidth = None
self.widgets = {}
self.widgets_pair = []
@ -124,18 +127,18 @@ class _Theme(object):
if not self.defaults:
# Can't initialise this til window is created # Windows, MacOS
self.defaults = {
'fg' : tk.Label()['foreground'], # SystemButtonText, systemButtonText
'bg' : tk.Label()['background'], # SystemButtonFace, White
'font' : tk.Label()['font'], # TkDefaultFont
'bitmapfg' : tk.BitmapImage()['foreground'], # '-foreground {} {} #000000 #000000'
'bitmapbg' : tk.BitmapImage()['background'], # '-background {} {} {} {}'
'entryfg' : tk.Entry()['foreground'], # SystemWindowText, Black
'entrybg' : tk.Entry()['background'], # SystemWindow, systemWindowBody
'entryfont' : tk.Entry()['font'], # TkTextFont
'frame' : tk.Frame()['background'], # SystemButtonFace, systemWindowBody
'menufg' : tk.Menu()['foreground'], # SystemMenuText,
'menubg' : tk.Menu()['background'], # SystemMenu,
'menufont' : tk.Menu()['font'], # TkTextFont
'fg': tk.Label()['foreground'], # SystemButtonText, systemButtonText
'bg': tk.Label()['background'], # SystemButtonFace, White
'font': tk.Label()['font'], # TkDefaultFont
'bitmapfg': tk.BitmapImage()['foreground'], # '-foreground {} {} #000000 #000000'
'bitmapbg': tk.BitmapImage()['background'], # '-background {} {} {} {}'
'entryfg': tk.Entry()['foreground'], # SystemWindowText, Black
'entrybg': tk.Entry()['background'], # SystemWindow, systemWindowBody
'entryfont': tk.Entry()['font'], # TkTextFont
'frame': tk.Frame()['background'], # SystemButtonFace, systemWindowBody
'menufg': tk.Menu()['foreground'], # SystemMenuText,
'menubg': tk.Menu()['background'], # SystemMenu,
'menufont': tk.Menu()['font'], # TkTextFont
}
if widget not in self.widgets:
@ -189,26 +192,27 @@ class _Theme(object):
def _enter(self, event, image):
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
widget.configure(state = tk.ACTIVE)
widget.configure(state=tk.ACTIVE)
if image:
image.configure(foreground = self.current['activeforeground'], background = self.current['activebackground'])
image.configure(foreground=self.current['activeforeground'],
background=self.current['activebackground'])
def _leave(self, event, image):
widget = event.widget
if widget and widget['state'] != tk.DISABLED:
widget.configure(state = tk.NORMAL)
widget.configure(state=tk.NORMAL)
if image:
image.configure(foreground = self.current['foreground'], background = self.current['background'])
image.configure(foreground=self.current['foreground'], background=self.current['background'])
# Set up colors
def _colors(self, root, theme):
style = ttk.Style()
if platform == 'linux':
if sys.platform == 'linux':
style.theme_use('clam')
# Default dark theme colors
if not config.get_str('dark_text'):
config.set('dark_text', '#ff8000') # "Tangerine" in OSX color picker
config.set('dark_text', '#ff8000') # "Tangerine" in OSX color picker
if not config.get_str('dark_highlight'):
config.set('dark_highlight', 'white')
@ -216,40 +220,40 @@ class _Theme(object):
# Dark
(r, g, b) = root.winfo_rgb(config.get_str('dark_text'))
self.current = {
'background' : 'grey4', # OSX inactive dark titlebar color
'foreground' : config.get_str('dark_text'),
'activebackground' : config.get_str('dark_text'),
'activeforeground' : 'grey4',
'disabledforeground' : '#%02x%02x%02x' % (int(r/384), int(g/384), int(b/384)),
'highlight' : config.get_str('dark_highlight'),
'background': 'grey4', # OSX inactive dark titlebar color
'foreground': config.get_str('dark_text'),
'activebackground': config.get_str('dark_text'),
'activeforeground': 'grey4',
'disabledforeground': '#%02x%02x%02x' % (int(r/384), int(g/384), int(b/384)),
'highlight': config.get_str('dark_highlight'),
# Font only supports Latin 1 / Supplement / Extended, and a few General Punctuation and Mathematical Operators
# LANG: Label for commander name in main window
'font' : (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and
tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or
'TkDefaultFont'),
'font': (theme > 1 and not 0x250 < ord(_('Cmdr')[0]) < 0x3000 and
tkFont.Font(family='Euro Caps', size=10, weight=tkFont.NORMAL) or
'TkDefaultFont'),
}
else:
# (Mostly) system colors
style = ttk.Style()
self.current = {
'background' : (platform == 'darwin' and 'systemMovableModalBackground' or
style.lookup('TLabel', 'background')),
'foreground' : style.lookup('TLabel', 'foreground'),
'activebackground' : (platform == 'win32' and 'SystemHighlight' or
style.lookup('TLabel', 'background', ['active'])),
'activeforeground' : (platform == 'win32' and 'SystemHighlightText' or
style.lookup('TLabel', 'foreground', ['active'])),
'disabledforeground' : style.lookup('TLabel', 'foreground', ['disabled']),
'highlight' : 'blue',
'font' : 'TkDefaultFont',
'background': (sys.platform == 'darwin' and 'systemMovableModalBackground' or
style.lookup('TLabel', 'background')),
'foreground': style.lookup('TLabel', 'foreground'),
'activebackground': (sys.platform == 'win32' and 'SystemHighlight' or
style.lookup('TLabel', 'background', ['active'])),
'activeforeground': (sys.platform == 'win32' and 'SystemHighlightText' or
style.lookup('TLabel', 'foreground', ['active'])),
'disabledforeground': style.lookup('TLabel', 'foreground', ['disabled']),
'highlight': 'blue',
'font': 'TkDefaultFont',
}
# Apply current theme to a widget and its children, and register it for future updates
def update(self, widget):
assert isinstance(widget, tk.Widget) or isinstance(widget, tk.BitmapImage), widget
if not self.current:
return # No need to call this for widgets created in plugin_app()
return # No need to call this for widgets created in plugin_app()
self.register(widget)
self._update_widget(widget)
if isinstance(widget, tk.Frame) or isinstance(widget, ttk.Frame):
@ -258,56 +262,57 @@ class _Theme(object):
# Apply current theme to a single widget
def _update_widget(self, widget):
assert widget in self.widgets, '%s %s "%s"' %(widget.winfo_class(), widget, 'text' in widget.keys() and widget['text'])
assert widget in self.widgets, '%s %s "%s"' % (
widget.winfo_class(), widget, 'text' in widget.keys() and widget['text'])
attribs = self.widgets.get(widget, [])
if isinstance(widget, tk.BitmapImage):
# not a widget
if 'fg' not in attribs:
widget.configure(foreground = self.current['foreground']),
widget.configure(foreground=self.current['foreground']),
if 'bg' not in attribs:
widget.configure(background = self.current['background'])
widget.configure(background=self.current['background'])
elif 'cursor' in widget.keys() and str(widget['cursor']) not in ['', 'arrow']:
# Hack - highlight widgets like HyperlinkLabel with a non-default cursor
if 'fg' not in attribs:
widget.configure(foreground = self.current['highlight']),
if 'insertbackground' in widget.keys(): # tk.Entry
widget.configure(insertbackground = self.current['foreground']),
widget.configure(foreground=self.current['highlight']),
if 'insertbackground' in widget.keys(): # tk.Entry
widget.configure(insertbackground=self.current['foreground']),
if 'bg' not in attribs:
widget.configure(background = self.current['background'])
if 'highlightbackground' in widget.keys(): # tk.Entry
widget.configure(highlightbackground = self.current['background'])
widget.configure(background=self.current['background'])
if 'highlightbackground' in widget.keys(): # tk.Entry
widget.configure(highlightbackground=self.current['background'])
if 'font' not in attribs:
widget.configure(font = self.current['font'])
widget.configure(font=self.current['font'])
elif 'activeforeground' in widget.keys():
# e.g. tk.Button, tk.Label, tk.Menu
if 'fg' not in attribs:
widget.configure(foreground = self.current['foreground'],
activeforeground = self.current['activeforeground'],
disabledforeground = self.current['disabledforeground'])
widget.configure(foreground=self.current['foreground'],
activeforeground=self.current['activeforeground'],
disabledforeground=self.current['disabledforeground'])
if 'bg' not in attribs:
widget.configure(background = self.current['background'],
activebackground = self.current['activebackground'])
if platform == 'darwin' and isinstance(widget, tk.Button):
widget.configure(highlightbackground = self.current['background'])
widget.configure(background=self.current['background'],
activebackground=self.current['activebackground'])
if sys.platform == 'darwin' and isinstance(widget, tk.Button):
widget.configure(highlightbackground=self.current['background'])
if 'font' not in attribs:
widget.configure(font = self.current['font'])
widget.configure(font=self.current['font'])
elif 'foreground' in widget.keys():
# e.g. ttk.Label
if 'fg' not in attribs:
widget.configure(foreground = self.current['foreground']),
widget.configure(foreground=self.current['foreground']),
if 'bg' not in attribs:
widget.configure(background = self.current['background'])
widget.configure(background=self.current['background'])
if 'font' not in attribs:
widget.configure(font = self.current['font'])
widget.configure(font=self.current['font'])
elif 'background' in widget.keys() or isinstance(widget, tk.Canvas):
# e.g. Frame, Canvas
if 'bg' not in attribs:
widget.configure(background = self.current['background'],
highlightbackground = self.current['disabledforeground'])
widget.configure(background=self.current['background'],
highlightbackground=self.current['disabledforeground'])
# Apply configured theme
def apply(self, root):
theme = config.get_int('theme')
@ -316,7 +321,7 @@ class _Theme(object):
# Apply colors
for widget in set(self.widgets):
if isinstance(widget, tk.Widget) and not widget.winfo_exists():
self.widgets.pop(widget) # has been destroyed
self.widgets.pop(widget) # has been destroyed
else:
self._update_widget(widget)
@ -334,58 +339,61 @@ class _Theme(object):
pair[theme].grid(**gridopts)
if self.active == theme:
return # Don't need to mess with the window manager
return # Don't need to mess with the window manager
else:
self.active = theme
if platform == 'darwin':
if sys.platform == 'darwin':
from AppKit import NSAppearance, NSApplication, NSMiniaturizableWindowMask, NSResizableWindowMask
root.update_idletasks() # need main window to be created
root.update_idletasks() # need main window to be created
appearance = NSAppearance.appearanceNamed_(theme and
'NSAppearanceNameDarkAqua' or
'NSAppearanceNameAqua')
for window in NSApplication.sharedApplication().windows():
window.setStyleMask_(window.styleMask() & ~(NSMiniaturizableWindowMask | NSResizableWindowMask)) # disable zoom
window.setStyleMask_(window.styleMask() & ~(
NSMiniaturizableWindowMask | NSResizableWindowMask)) # disable zoom
window.setAppearance_(appearance)
elif platform == 'win32':
elif sys.platform == 'win32':
GWL_STYLE = -16
WS_MAXIMIZEBOX = 0x00010000
WS_MAXIMIZEBOX = 0x00010000
# tk8.5.9/win/tkWinWm.c:342
GWL_EXSTYLE = -20
WS_EX_APPWINDOW = 0x00040000
WS_EX_LAYERED = 0x00080000
WS_EX_APPWINDOW = 0x00040000
WS_EX_LAYERED = 0x00080000
GetWindowLongW = ctypes.windll.user32.GetWindowLongW
SetWindowLongW = ctypes.windll.user32.SetWindowLongW
root.overrideredirect(theme and 1 or 0)
root.attributes("-transparentcolor", theme > 1 and 'grey4' or '')
root.withdraw()
root.update_idletasks() # Size and windows styles get recalculated here
root.update_idletasks() # Size and windows styles get recalculated here
hwnd = ctypes.windll.user32.GetParent(root.winfo_id())
SetWindowLongW(hwnd, GWL_STYLE, GetWindowLongW(hwnd, GWL_STYLE) & ~WS_MAXIMIZEBOX) # disable maximize
SetWindowLongW(hwnd, GWL_EXSTYLE, theme > 1 and WS_EX_APPWINDOW|WS_EX_LAYERED or WS_EX_APPWINDOW) # Add to taskbar
SetWindowLongW(hwnd, GWL_STYLE, GetWindowLongW(hwnd, GWL_STYLE) & ~WS_MAXIMIZEBOX) # disable maximize
SetWindowLongW(hwnd, GWL_EXSTYLE, theme > 1 and WS_EX_APPWINDOW |
WS_EX_LAYERED or WS_EX_APPWINDOW) # Add to taskbar
root.deiconify()
root.wait_visibility() # need main window to be displayed before returning
root.wait_visibility() # need main window to be displayed before returning
else:
root.withdraw()
root.update_idletasks() # Size gets recalculated here
root.update_idletasks() # Size gets recalculated here
if dpy:
xroot = Window()
parent = Window()
children = Window()
nchildren = c_uint()
XQueryTree(dpy, root.winfo_id(), byref(xroot), byref(parent), byref(children), byref(nchildren))
XChangeProperty(dpy, parent, motif_wm_hints_property, motif_wm_hints_property, 32, PropModeReplace, theme and motif_wm_hints_dark or motif_wm_hints_normal, 5)
XChangeProperty(dpy, parent, motif_wm_hints_property, motif_wm_hints_property, 32,
PropModeReplace, theme and motif_wm_hints_dark or motif_wm_hints_normal, 5)
XFlush(dpy)
else:
root.overrideredirect(theme and 1 or 0)
root.deiconify()
root.wait_visibility() # need main window to be displayed before returning
root.wait_visibility() # need main window to be displayed before returning
if not self.minwidth:
self.minwidth = root.winfo_width() # Minimum width = width on first creation
self.minwidth = root.winfo_width() # Minimum width = width on first creation
root.minsize(self.minwidth, -1)

View File

@ -1,22 +1,24 @@
"""A requests.session with a TimeoutAdapter."""
import requests
from requests.adapters import HTTPAdapter
from config import user_agent
REQUEST_TIMEOUT = 10 # reasonable timeout that all HTTP requests should use
class TimeoutAdapter(HTTPAdapter):
"""
TimeoutAdapter is an HTTP Adapter that enforces an overridable default timeout on HTTP requests.
"""
def __init__(self, timeout, *args, **kwargs):
"""An HTTP Adapter that enforces an overridable default timeout on HTTP requests."""
def __init__(self, timeout: int, *args, **kwargs):
self.default_timeout = timeout
if kwargs.get("timeout") is not None:
del kwargs["timeout"]
super().__init__(*args, **kwargs)
def send(self, *args, **kwargs):
def send(self, *args, **kwargs) -> requests.Response:
"""Send, but with a timeout always set."""
if kwargs["timeout"] is None:
kwargs["timeout"] = self.default_timeout
@ -25,7 +27,7 @@ class TimeoutAdapter(HTTPAdapter):
def new_session(timeout: int = REQUEST_TIMEOUT, session: requests.Session = None) -> requests.Session:
"""
new_session creates a new requests.Session and overrides the default HTTPAdapter with a TimeoutAdapter.
Create a new requests.Session and override the default HTTPAdapter with a TimeoutAdapter.
:param timeout: the timeout to set the TimeoutAdapter to, defaults to REQUEST_TIMEOUT
:param session: the Session object to attach the Adapter to, defaults to a new session
@ -33,6 +35,7 @@ def new_session(timeout: int = REQUEST_TIMEOUT, session: requests.Session = None
"""
if session is None:
session = requests.Session()
session.headers['User-Agent'] = user_agent
adapter = TimeoutAdapter(timeout)
session.mount("http://", adapter)

View File

@ -1,14 +1,12 @@
import sys
import tkinter as tk
import webbrowser
from sys import platform
from tkinter import font as tkFont
from tkinter import ttk
if platform == 'win32':
if sys.platform == 'win32':
import subprocess
from winreg import (
HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx
)
from winreg import HKEY_CLASSES_ROOT, HKEY_CURRENT_USER, CloseKey, OpenKeyEx, QueryValueEx
# A clickable ttk Label
#
@ -18,19 +16,22 @@ if platform == 'win32':
# popup_copy: Whether right-click on non-empty label text pops up a context menu with a 'Copy' option. Defaults to no context menu. If popup_copy is a function it will be called with the current label text and should return a boolean.
#
# May be imported by plugins
class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
class HyperlinkLabel(sys.platform == 'darwin' and tk.Label or ttk.Label, object):
def __init__(self, master=None, **kw):
self.url = 'url' in kw and kw.pop('url') or None
self.popup_copy = kw.pop('popup_copy', False)
self.underline = kw.pop('underline', None) # override ttk.Label's underline
self.underline = kw.pop('underline', None) # override ttk.Label's underline
self.foreground = kw.get('foreground') or 'blue'
self.disabledforeground = kw.pop('disabledforeground', ttk.Style().lookup('TLabel', 'foreground', ('disabled',))) # ttk.Label doesn't support disabledforeground option
self.disabledforeground = kw.pop('disabledforeground', ttk.Style().lookup(
'TLabel', 'foreground', ('disabled',))) # ttk.Label doesn't support disabledforeground option
if platform == 'darwin':
if sys.platform == 'darwin':
# Use tk.Label 'cos can't set ttk.Label background - http://www.tkdocs.com/tutorial/styles.html#whydifficult
kw['background'] = kw.pop('background', 'systemDialogBackgroundActive')
kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label
kw['anchor'] = kw.pop('anchor', tk.W) # like ttk.Label
tk.Label.__init__(self, master, **kw)
else:
ttk.Label.__init__(self, master, **kw)
@ -39,16 +40,16 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
self.menu = tk.Menu(None, tearoff=tk.FALSE)
# LANG: Label for 'Copy' as in 'Copy and Paste'
self.menu.add_command(label=_('Copy'), command = self.copy) # As in Copy and Paste
self.bind(platform == 'darwin' and '<Button-2>' or '<Button-3>', self._contextmenu)
self.menu.add_command(label=_('Copy'), command=self.copy) # As in Copy and Paste
self.bind(sys.platform == 'darwin' and '<Button-2>' or '<Button-3>', self._contextmenu)
self.bind('<Enter>', self._enter)
self.bind('<Leave>', self._leave)
# set up initial appearance
self.configure(state = kw.get('state', tk.NORMAL),
text = kw.get('text'),
font = kw.get('font', ttk.Style().lookup('TLabel', 'font')))
self.configure(state=kw.get('state', tk.NORMAL),
text=kw.get('text'),
font=kw.get('font', ttk.Style().lookup('TLabel', 'font')))
# Change cursor and appearance depending on state and text
def configure(self, cnf=None, **kw):
@ -70,17 +71,18 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
if 'font' in kw:
self.font_n = kw['font']
self.font_u = tkFont.Font(font = self.font_n)
self.font_u.configure(underline = True)
self.font_u = tkFont.Font(font=self.font_n)
self.font_u.configure(underline=True)
kw['font'] = self.underline is True and self.font_u or self.font_n
if 'cursor' not in kw:
if (kw['state'] if 'state' in kw else str(self['state'])) == tk.DISABLED:
kw['cursor'] = 'arrow' # System default
kw['cursor'] = 'arrow' # System default
elif self.url and (kw['text'] if 'text' in kw else self['text']):
kw['cursor'] = platform=='darwin' and 'pointinghand' or 'hand2'
kw['cursor'] = sys.platform == 'darwin' and 'pointinghand' or 'hand2'
else:
kw['cursor'] = (platform=='darwin' and 'notallowed') or (platform=='win32' and 'no') or 'circle'
kw['cursor'] = (sys.platform == 'darwin' and 'notallowed') or (
sys.platform == 'win32' and 'no') or 'circle'
super(HyperlinkLabel, self).configure(cnf, **kw)
@ -89,22 +91,22 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
def _enter(self, event):
if self.url and self.underline is not False and str(self['state']) != tk.DISABLED:
super(HyperlinkLabel, self).configure(font = self.font_u)
super(HyperlinkLabel, self).configure(font=self.font_u)
def _leave(self, event):
if not self.underline:
super(HyperlinkLabel, self).configure(font = self.font_n)
super(HyperlinkLabel, self).configure(font=self.font_n)
def _click(self, event):
if self.url and self['text'] and str(self['state']) != tk.DISABLED:
url = self.url(self['text']) if callable(self.url) else self.url
if url:
self._leave(event) # Remove underline before we change window to browser
self._leave(event) # Remove underline before we change window to browser
openurl(url)
def _contextmenu(self, event):
if self['text'] and (self.popup_copy(self['text']) if callable(self.popup_copy) else self.popup_copy):
self.menu.post(platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root)
self.menu.post(sys.platform == 'darwin' and event.x_root + 1 or event.x_root, event.y_root)
def copy(self):
self.clipboard_clear()
@ -112,13 +114,14 @@ class HyperlinkLabel(platform == 'darwin' and tk.Label or ttk.Label, object):
def openurl(url):
if platform == 'win32':
if sys.platform == 'win32':
# On Windows webbrowser.open calls os.startfile which calls ShellExecute which can't handle long arguments,
# so discover and launch the browser directly.
# https://blogs.msdn.microsoft.com/oldnewthing/20031210-00/?p=41553
try:
hkey = OpenKeyEx(HKEY_CURRENT_USER, r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice')
hkey = OpenKeyEx(HKEY_CURRENT_USER,
r'Software\Microsoft\Windows\Shell\Associations\UrlAssociations\https\UserChoice')
(value, typ) = QueryValueEx(hkey, 'ProgId')
CloseKey(hkey)
if value in ['IE.HTTP', 'AppXq0fevzme2pys62n3e0fbqa7peapykr8v']:
@ -128,7 +131,7 @@ def openurl(url):
else:
cls = value
except:
cls = 'https'
cls = 'https'
if cls:
try:

27
util/text.py Normal file
View File

@ -0,0 +1,27 @@
"""Utilities for dealing with text (and byte representations thereof)."""
from __future__ import annotations
from gzip import compress
__all__ = ['gzip']
def gzip(data: str | bytes, max_size: int = 512, encoding='utf-8') -> tuple[bytes, bool]:
"""
Compress the given data if the max size is greater than specified.
The default was chosen somewhat arbitrarily, see eddn.py for some more careful
work towards keeping the data almost always compressed
:param data: The data to compress
:param max_size: The max size of data, in bytes, defaults to 512
:param encoding: The encoding to use if data is a str, defaults to 'utf-8'
:return: the payload to send, and a bool indicating compression state
"""
if isinstance(data, str):
data = data.encode(encoding=encoding)
if len(data) <= max_size:
return data, False
return compress(data), True

File diff suppressed because it is too large Load Diff

125
wix/template.wxs Normal file
View File

@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<?define PRODUCTNAME = "EDMarketConnector"?>
<?define PRODUCTLONGNAME = "Elite Dangerous Market Connector"?>
<?define PRODUCTVERSION = "!(bind.fileVersion.EDMarketConnector.exe)" ?>
<?define UPGRADECODE = "9df571ae-d56d-46e6-af79-4e72ad54efe6" ?>
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
<Product Id="*"
Name="$(var.PRODUCTLONGNAME)"
Version="$(var.PRODUCTVERSION)"
UpgradeCode="$(var.UPGRADECODE)"
Language="!(bind.fileLanguage.EDMarketConnector.exe)"
Manufacturer="EDCD">
<Package Id="*" Keywords="Installer"
InstallScope="perMachine"
Description="$(var.PRODUCTLONGNAME) installer"
InstallerVersion="300" Compressed="yes"
Platform="x86"
Languages="1033,1029,1031,1034,1035,1036,1038,1040,1041,1043,1045,1046,1049,1058,1062,2052,2070,2074,6170,1060,1053,18,0" />
<!-- en cs, de es fi fr hu it ja nl pl pt-BR ru uk lv zh-CN pt-PT sr-Latn sr-Latn-BA sl sv-SE ko neutral -->
<!-- https://msdn.microsoft.com/en-gb/goglobal/bb964664.aspx -->
<!-- Always reinstall since patching is problematic -->
<!-- http://www.joyofsetup.com/2010/01/16/major-upgrades-now-easier-than-ever/ -->
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A newer version of [ProductName] is already installed." />
<Media Id="1" Cabinet="product.cab" EmbedCab="yes" />
<Icon Id="EDMarketConnector.exe" SourceFile="EDMarketConnector.ico"/>
<!-- For Add/Remove programs -->
<Property Id="ARPPRODUCTICON" Value="EDMarketConnector.exe" />
<Property Id="ARPNOMODIFY" Value="yes" Secure="yes" /> <!-- Remove modify - also set by WixUI_Minimal -->
<Property Id="ARPHELPLINK" Value="https://github.com/EDCD/EDMarketConnector/wiki" />
<!-- Set INSTALLDIR from ARPINSTALLLOCATION if replacing/upgrading -->
<!-- https://wyrdfish.wordpress.com/2012/07/20/msi-writing-guidelines-this-may-be-out-of-date/ -->
<Property Id="ARPINSTALLLOCATION">
<RegistrySearch Id="GetARPINSTALLLOCATION"
Root="HKLM"
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\[WIX_UPGRADE_DETECTED]"
Name="InstallLocation"
Type="raw" />
</Property>
<CustomAction Id="SetINSTALLDIR" Property="INSTALLDIR" Value="[ARPINSTALLLOCATION]" />
<InstallUISequence>
<Custom Action="SetINSTALLDIR" After="AppSearch">
WIX_UPGRADE_DETECTED AND ARPINSTALLLOCATION
</Custom>
</InstallUISequence>
<InstallExecuteSequence>
<Custom Action="SetINSTALLDIR" After="AppSearch">
WIX_UPGRADE_DETECTED AND ARPINSTALLLOCATION
</Custom>
</InstallExecuteSequence>
<!-- Set ARPINSTALLLOCATION from INSTALLDIR if new install -->
<!-- http://blogs.technet.com/b/alexshev/archive/2008/02/09/from-msi-to-wix-part-2.aspx -->
<CustomAction Id="SetARPINSTALLLOCATION" Property="ARPINSTALLLOCATION" Value="[INSTALLDIR]" />
<InstallExecuteSequence>
<Custom Action="SetARPINSTALLLOCATION" After="InstallValidate">
NOT Installed
</Custom>
</InstallExecuteSequence>
<!-- Launch app after upgrade -->
<Property Id="LAUNCH" Value="yes" />
<CustomAction Id="DoLaunch"
Directory="INSTALLDIR"
ExeCommand='"[INSTALLDIR]EDMarketConnector.exe"'
Return="asyncNoWait"
Execute="deferred"
Impersonate="yes"
/>
<InstallExecuteSequence>
<!-- http://alekdavis.blogspot.co.uk/2013/05/wix-woes-what-is-your-installer-doing.html -->
<Custom Action="DoLaunch" Before="InstallFinalize">
NOT Installed AND LAUNCH ~= "yes"
</Custom>
</InstallExecuteSequence>
<Directory Id="TARGETDIR" Name="SourceDir">
<!-- http://wixtoolset.org/documentation/manual/v3/howtos/files_and_registry/write_a_registry_entry.html -->
<Component Id="RegistryEntries" Guid="*">
<RegistryKey Root="HKCR" Key="edmc">
<RegistryValue Type="string" Value="$(var.PRODUCTLONGNAME)"/>
<RegistryValue Type="string" Name="URL Protocol" Value=""/>
<RegistryKey Key="DefaultIcon">
<RegistryValue Type="string" Value="[INSTALLDIR]EDMarketConnector.exe,0"/>
</RegistryKey>
<RegistryKey Key="shell">
<RegistryKey Key="open">
<RegistryKey Key="command">
<RegistryValue Type="string" Value='"[INSTALLDIR]EDMarketConnector.exe" "%1"'/>
</RegistryKey>
<RegistryKey Key="ddeexec">
<RegistryValue Type="string" Value='Open("%1")'/>
</RegistryKey>
</RegistryKey>
</RegistryKey>
</RegistryKey>
</Component>
<!-- Contents auto-generated with heat.exe, see setup.py -->
<Directory Id="ProgramFilesFolder">
</Directory>
<Directory Id="ProgramMenuFolder" Name="Programs">
</Directory>
</Directory>
<!-- Contents auto-generated in setup.py -->
<Feature Id='Complete' Level='1'>
</Feature>
</Product>
</Wix>
<!-- Local Variables: -->
<!-- tab-width: 4 -->
<!-- End: -->