From 10e4976bf41dfb2eee24b0075082498c1a1152e9 Mon Sep 17 00:00:00 2001 From: Ghawken Date: Sun, 19 Nov 2023 09:05:33 +1100 Subject: [PATCH] 0.7.12 With Sonoma apple decided the iMsg database would store the message data in a Apple NSString Archived object. Taking up oodles more room. It appears the plain text is quickly converted and then becomes NULL. Update plugin to decode message.attributedbody using typedstream and decoded contents of NSString formatting. Testing... 0.7.10 Move model to gpt-3.5-16k Increase token count to 8000 TODO user selectable both above when time 0.7.9 Model back to gpt-3.5 & remove some while loops trying to track down occasional 'hang' 0.7.8 Change model to gpt-4 when rolls out for all. 0.7.7 Update openai library to 0.27.4 0.7.6 Add chatGPT only Buddy - these individuals can only converse with chatGPT. They won't have any trigger or command options If device control enabled will allow device Control, but increasingly not sure this is a great idea for this plugin 0.7.5 Handle and specifically message regarding timeout, ratelimt openai errors 0.7.4 OpenAI reports that the system prompt is largely ignored with user prompt having more 'attention' Because of this duplicate some setup into a new user prompt 0.7.3 Fix for debugextra logging string conversion issue (also in 0.7.2) Better checking for token usage and deletion of prompts before gets to limit Add 2nd Pluginconfig Personal info: Aim of this is to educate chatGPT as to who the various users/buddies are. Should be Buddy Handle followed by | and then description written in first person. buddyhandle|I am user Glenn.I am.. |buddyhandle2|I am user Glenns co-worker. Probably would better be a setup file, as really need to type somewhere else and paste in... ## 0.7.1 Fix for quotes ## New 0.7.0 Add support for ChatGPT 3.5 turbo API usage.(Beta) This can be used to control indigo devices (so marked for control), like wit.ai - however it probably needs a bit of maturing before that works 100% Currently though the chatbot, chat, information, advice function via chatGPT works very well and enables easy access to chatGPT replies for whatever usage. Like chatGPT warning - accuracy here depends on the subject, but for natural language processing it is great. --- iMessage.indigoPlugin/Contents/Info.plist | 2 +- .../Contents/Server Plugin/plugin.py | 36 +- .../Server Plugin/typedstream/__init__.py | 47 + .../Server Plugin/typedstream/__main__.py | 180 +++ .../typedstream/advanced_repr.py | 189 +++ .../Server Plugin/typedstream/archiving.py | 870 ++++++++++++ .../Server Plugin/typedstream/encodings.py | 232 ++++ .../typedstream/old_binary_plist.py | 185 +++ .../Server Plugin/typedstream/stream.py | 1004 ++++++++++++++ .../typedstream/types/__init__.py | 23 + .../typedstream/types/_common.py | 43 + .../Server Plugin/typedstream/types/appkit.py | 1201 +++++++++++++++++ .../typedstream/types/core_graphics.py | 112 ++ .../typedstream/types/foundation.py | 305 +++++ .../typedstream/types/nextstep.py | 200 +++ 15 files changed, 4622 insertions(+), 7 deletions(-) create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__init__.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__main__.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/advanced_repr.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/archiving.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/encodings.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/old_binary_plist.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/stream.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/__init__.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/_common.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/appkit.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/core_graphics.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/foundation.py create mode 100644 iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/nextstep.py diff --git a/iMessage.indigoPlugin/Contents/Info.plist b/iMessage.indigoPlugin/Contents/Info.plist index e3bc015..d75bb8a 100644 --- a/iMessage.indigoPlugin/Contents/Info.plist +++ b/iMessage.indigoPlugin/Contents/Info.plist @@ -3,7 +3,7 @@ PluginVersion - 0.7.11 + 0.7.12 ServerApiVersion 3.0.0 IwsApiVersion diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/plugin.py b/iMessage.indigoPlugin/Contents/Server Plugin/plugin.py index 7c9b8f9..7d5cb15 100644 --- a/iMessage.indigoPlugin/Contents/Server Plugin/plugin.py +++ b/iMessage.indigoPlugin/Contents/Server Plugin/plugin.py @@ -28,6 +28,7 @@ import re import random import datetime +import typedstream try: import indigo @@ -448,7 +449,7 @@ def sql_fetchmessages(self): # if self.debugextra: # self.debugLog(u"fetch messages() method called.") cursor = self.connection.cursor() - + using_attributedBody = False #below is needed for older than Mojave if self.systemVersion >=22: sqlcommand = ''' @@ -459,6 +460,7 @@ def sql_fetchmessages(self): datetime(message.date/1000000000 + strftime("%s", "2001-01-01") ,"unixepoch","localtime") >= datetime('now','-10 seconds', 'localtime') ORDER BY message.date ASC; ''' + using_attributedBody = True elif self.systemVersion >=17: sqlcommand = ''' SELECT handle.id, message.text, message.is_audio_message @@ -496,16 +498,38 @@ def sql_fetchmessages(self): return dict() else: if self.debugextra: - self.logger.debug(u'sql_fetchmessages: Not empty return:' + str(result)) - + self.logger.debug(u'sql_fetchmessages: Not empty return:\n' + str(result)) + self.logger.debug(f"Type result {type(result)}") newlist = [] - for items in result: + result_list = map(list, result) + if self.debugextra: + self.logger.debug(u'Convert to List of Lists\n' + str(result_list)) + + for items in result_list: + ## Given Ventura and Sonoma now uses messsage.attributedBody to save text as a Archive NSString. + ## Yikes. if items[2]==1: self.logger.debug(u'Must be audio file...') - newtuple = items[0], 'AUDIOFILE' + newtuple = [items[0], 'AUDIOFILE'] newlist.append(newtuple) else: - newtuple = items[0], items[1] + if using_attributedBody: + ## Need to read and convert the Hex NSString archived object back to Text + try: + bytes_data = bytes.fromhex(items[1]) + self.logger.debug(f"Using Attributed Body iMsg - thank you Apple.") + self.logger.debug(f"Converted Bytes_data {bytes_data}") + data = typedstream.unarchive_from_data(bytes_data) + if self.debugextra: + self.logger.debug(f"Extracted NSString archive: \n {data.contents}") + self.logger.debug(f"Message: == {data.contents[0].value.value}") + message = data.contents[0].value.value + items[1] = str(message) + except: + self.logger.exception("Exception unpacked NSString message.attributedbody") + pass + + newtuple = [items[0], items[1]] newlist.append(newtuple) self.logger.debug(u'newlist after checking audio file:'+str(newlist)) diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__init__.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__init__.py new file mode 100644 index 0000000..fdb6a69 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__init__.py @@ -0,0 +1,47 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +# "Unused" imports and star imports are ok in __init__.py. +from .stream import InvalidTypedStreamError # noqa: F401 +from .archiving import * # noqa: F401, F403 + +# .types and its submodules are normally not used directly, +# but it's important that they are imported, +# so that their classes are registered with KnownArchivedObject and KnownStruct. +from . import types # noqa: F401 + +# To release a new version: +# * Remove the .dev suffix from the version number in this file. +# * Update the changelog in the README.md (rename the "next version" section to the correct version number). +# * Remove the ``dist`` directory (if it exists) to clean up any old release files. +# * Run ``python3 setup.py sdist bdist_wheel`` to build the release files. +# * Run ``python3 -m twine check dist/*`` to check the release files. +# * Fix any errors reported by the build and/or check steps. +# * Commit the changes to main. +# * Tag the release commit with the version number, prefixed with a "v" (e. g. version 1.2.3 is tagged as v1.2.3). +# * Fast-forward the release branch to the new release commit. +# * Push the main and release branches. +# * Upload the release files to PyPI using ``python3 -m twine upload dist/*``. +# * On the GitHub repo's Releases page, edit the new release tag and add the relevant changelog section from the README.md. + +# After releasing: +# * (optional) Remove the build and dist directories from the previous release as they are no longer needed. +# * Bump the version number in this file to the next version and add a .dev suffix. +# * Add a new empty section for the next version to the README.md changelog. +# * Commit and push the changes to main. + +__version__ = "0.1.1.dev" diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__main__.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__main__.py new file mode 100644 index 0000000..512ff2b --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/__main__.py @@ -0,0 +1,180 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import argparse +import sys +import typing + + +from . import __version__ +from . import advanced_repr +from . import archiving +from . import stream + + +def make_subcommand_parser(subs: typing.Any, name: str, *, help: str, description: str, **kwargs: typing.Any) -> argparse.ArgumentParser: + """Add a subcommand parser with some slightly modified defaults to a subcommand set. + + This function is used to ensure that all subcommands use the same base configuration for their ArgumentParser. + """ + + ap = subs.add_parser( + name, + formatter_class=argparse.RawDescriptionHelpFormatter, + help=help, + description=description, + allow_abbrev=False, + add_help=False, + **kwargs, + ) + + ap.add_argument("--help", action="help", help="Display this help message and exit.") + + return ap + + +def open_typedstream_file(file: str) -> stream.TypedStreamReader: + if file == "-": + return stream.TypedStreamReader(sys.stdin.buffer) + else: + return stream.TypedStreamReader.open(file) + + +def dump_typedstream(ts: stream.TypedStreamReader) -> typing.Iterable[str]: + yield f"streamer version {ts.streamer_version}, byte order {ts.byte_order}, system version {ts.system_version}" + yield "" + indent = 0 + next_object_number = 0 + for event in ts: + if isinstance(event, (stream.EndTypedValues, stream.EndObject, stream.EndArray, stream.EndStruct)): + indent -= 1 + + rep = ("\t" * indent) + str(event) + if isinstance(event, (stream.CString, stream.SingleClass, stream.BeginObject)): + rep += f" (#{next_object_number})" + next_object_number += 1 + yield rep + + if isinstance(event, (stream.BeginTypedValues, stream.BeginObject, stream.BeginArray, stream.BeginStruct)): + indent += 1 + + +def do_read(ns: argparse.Namespace) -> typing.NoReturn: + with open_typedstream_file(ns.file) as ts: + for line in dump_typedstream(ts): + print(line) + + sys.exit(0) + + +def dump_decoded_typedstream(ts: stream.TypedStreamReader) -> typing.Iterable[str]: + unarchiver = archiving.Unarchiver(ts) + for obj in unarchiver.decode_all(): + yield from advanced_repr.as_multiline_string(obj) + + +def do_decode(ns: argparse.Namespace) -> typing.NoReturn: + with open_typedstream_file(ns.file) as ts: + for line in dump_decoded_typedstream(ts): + print(line) + + sys.exit(0) + + +def main() -> typing.NoReturn: + """Main function of the CLI. + + This function is a valid setuptools entry point. + Arguments are passed in sys.argv, + and every execution path ends with a sys.exit call. + (setuptools entry points are also permitted to return an integer, + which will be treated as an exit code. + We do not use this feature and instead always call sys.exit ourselves.) + """ + + ap = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=""" +%(prog)s is a tool for dumping typedstream files, which are produced by +the NSArchiver class in Apple's Foundation framework, as well as the +NXTypedStream APIs in the older NeXTSTEP OS. +""", + allow_abbrev=False, + add_help=False, + ) + + ap.add_argument("--help", action="help", help="Display this help message and exit.") + ap.add_argument("--version", action="version", version=__version__, help="Display version information and exit.") + + subs = ap.add_subparsers( + dest="subcommand", + metavar="SUBCOMMAND", + ) + + sub_read = make_subcommand_parser( + subs, + "read", + help="Read and display the raw contents of a typedstream.", + description=""" +Read and display the raw contents of a typedstream. + +All information is displayed as it's stored in the typedstream and is processed +as little as possible. In particular, object references are not resolved +(although each object's reference number is displayed, so that the references +can be followed manually), and objects aren't handled differently based on +their class. +""", + ) + sub_read.add_argument("file", help="The typedstream file to read, or - for stdin.") + + sub_decode = make_subcommand_parser( + subs, + "decode", + help="Read, decode and display the contents of a typedstream.", + description=""" +Read, decode and display the contents of a typedstream. + +Where possible, the data read from the typedstream is decoded into a +higher-level structure before being displayed. Objects are decoded based on +their class when their format is known and implemented. Objects of unknown +classes are also supported, but are decoded to a generic format based on the +typedstream data. + +As a result of this decoding, some low-level information from the typedstream +is discarded and not displayed, such as raw type encoding strings in known +classes, and object reference numbers. To see this low-level information, +use the read subcommand instead. +""", + ) + sub_decode.add_argument("file", help="The typedstream file to read, or - for stdin.") + + ns = ap.parse_args() + + if ns.subcommand is None: + print("Missing subcommand", file=sys.stderr) + sys.exit(2) + elif ns.subcommand == "read": + do_read(ns) + elif ns.subcommand == "decode": + do_decode(ns) + else: + print(f"Unknown subcommand: {ns.subcommand!r}", file=sys.stderr) + sys.exit(2) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/advanced_repr.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/advanced_repr.py new file mode 100644 index 0000000..5c98ca1 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/advanced_repr.py @@ -0,0 +1,189 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import contextvars +import typing + + +__all__ = [ + "prefix_lines", + "AsMultilineStringBase", + "as_multiline_string", +] + + +def prefix_lines( + lines: typing.Iterable[str], + *, + first: str = "", + rest: str = "", +) -> typing.Iterable[str]: + it = iter(lines) + + try: + yield first + next(it) + except StopIteration: + if first: + yield first + + if rest: + for line in it: + yield rest + line + else: + yield from it + + +_already_rendered_ids: "contextvars.ContextVar[typing.Set[int]]" = contextvars.ContextVar("_already_rendered_ids") +_currently_rendering_ids: "contextvars.ContextVar[typing.Tuple[int, ...]]" = contextvars.ContextVar("_currently_rendering_ids") + + +class AsMultilineStringBase(object): + """Base class for classes that want to implement a custom multiline string representation, + for use by :func:`as_multiline_string`. + + This also provides an implementation of ``__str__`` based on :meth:`~AsMultilineStringBase._as_multiline_string_`. + """ + + detect_backreferences: typing.ClassVar[bool] = True + + def _as_multiline_string_header_(self) -> str: + """Render the header part of this object's multiline string representation. + + The header should be a compact single-line overview description of the object. + Usually it should indicate the object's type + and other relevant attributes that can be represented in a short form, + e. g. the length of a collection. + + If the body part is non-empty, + then the header automatically has a colon appended. + + Because the header is always fully rendered even for multiple references to the same object, + it shouldn't recursively render other complex objects, + especially ones that might have cyclic references back to this object. + + :return: The string representation as an iterable of lines (line terminators not included). + """ + + raise NotImplementedError() + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + """Render the body part of this object's multiline string representation. + + The body is always rendered after the header, + so it shouldn't duplicate any information that is already part of the header. + + Each line in the body is automatically indented by one tab + so that the body appears visually nested under the header. + + :return: The string representation as an iterable of lines (line terminators not included). + """ + + raise NotImplementedError() + + def _as_multiline_string_(self) -> typing.Iterable[str]: + """Convert ``self`` to a multiline string representation. + + This method should not be called directly - + use :func:`as_multiline_string` instead. + + The default implementation is based on :meth:`_as_multiline_string_header_` and :meth:`_as_multiline_string_body_`. + It first outputs the header on its own line, + then all of the body lines indented by one tab each. + If this object has already been rendered before + (due to multiple or circular references), + then the body lines are *not* rendered again + and only the header is output + (followed by a short explanation). + + If the default implementation of this method is overridden, + then :meth:`_as_multiline_string_header_` and :meth:`_as_multiline_string_body_` don't have to be implemented. + + :return: The string representation as an iterable of lines (line terminators not included). + """ + + first = self._as_multiline_string_header_() + if id(self) in _currently_rendering_ids.get()[:-1]: # last element of _currently_rendering_ids is always id(self) + yield first + " (circular reference)" + elif type(self).detect_backreferences and id(self) in _already_rendered_ids.get(): + yield first + " (backreference)" + else: + body_it = iter(self._as_multiline_string_body_()) + # Silly hack: append the colon to the first line only if at least one more line comes after it. + try: + second = next(body_it) + except StopIteration: + yield first + else: + yield first + ":" + yield "\t" + second + for line in body_it: + yield "\t" + line + + def __str__(self) -> str: + return "\n".join(self._as_multiline_string_()) + + +def as_multiline_string(obj: object, *, prefix: str = "") -> typing.Iterable[str]: + """Convert an object to a multiline string representation. + + If the object has an :meth:`~AsMultilineStringBase._as_multiline_string_` method, + it is used to create the multiline string representation. + Otherwise, + the object is converted to a string using default :class:`str` conversion, + and then split into an iterable of lines. + + :param obj: The object to represent. + :param prefix: An optional prefix to add in front of the first line of the string representation. + Convenience shortcut for :func:`prefix_lines`. + :return: The string representation as an iterable of lines (line terminators not included). + """ + + already_rendered_ids: typing.Optional[typing.Set[int]] = None + token: typing.Optional[contextvars.Token] = None + token2: typing.Optional[contextvars.Token] = None + + try: + try: + already_rendered_ids = _already_rendered_ids.get() + except LookupError: + already_rendered_ids = set() + token = _already_rendered_ids.set(already_rendered_ids) + + try: + currently_rendering_ids = _currently_rendering_ids.get() + except LookupError: + currently_rendering_ids = () + else: + already_rendered_ids.add(currently_rendering_ids[-1]) + + token2 = _currently_rendering_ids.set(currently_rendering_ids + (id(obj),)) + + if isinstance(obj, AsMultilineStringBase): + res = obj._as_multiline_string_() + else: + res = str(obj).splitlines() + + yield from prefix_lines(res, first=prefix) + finally: + if already_rendered_ids is not None: + already_rendered_ids.add(id(obj)) + + if token2 is not None: + _currently_rendering_ids.reset(token2) + + if token is not None: + _already_rendered_ids.reset(token) diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/archiving.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/archiving.py new file mode 100644 index 0000000..ffd9c1d --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/archiving.py @@ -0,0 +1,870 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import abc +import os +import types +import typing + +from . import advanced_repr +from . import encodings +from . import old_binary_plist +from . import stream + + +__all__ = [ + "TypedGroup", + "TypedValue", + "Class", + "GenericArchivedObject", + "KnownArchivedObject", + "archived_classes_by_name", + "register_archived_class", + "archived_class", + "lookup_archived_class", + "instantiate_archived_class", + "GenericStruct", + "KnownStruct", + "struct_classes_by_encoding", + "register_struct_class", + "struct_class", + "Unarchiver", + "unarchive_from_stream", + "unarchive_from_data", + "unarchive_from_file", +] + + +class TypedGroup(advanced_repr.AsMultilineStringBase): + """Representation of a group of typed values packed together in a typedstream. + + Value groups in a typedstream are created by serializing multiple values with a single call to ``-[NSArchiver encodeValuesOfObjCTypes:]``. + This produces different serialized data than calling ``-[NSArchiver encodeValueOfObjCType:at:]`` separately for each of the values. + The former serializes all values' type encodings joined together into a single string, + followed by all of the values one immediately after another. + The latter serializes each value as a separate encoding/value pair. + + A :class:`TypedGroup` instance returned by :class:`Unarchiver` always contains at least one value + (groups with exactly one value are represented using the subclass :class:`TypedValue` instead). + Empty groups are technically supported by the typedstream format, + but :class:`Unarchiver` treats them as an error, + as they are never used in practice. + """ + + detect_backreferences = False + + encodings: typing.Sequence[bytes] + values: typing.Sequence[typing.Any] + + def __init__(self, encodings: typing.Sequence[bytes], values: typing.Sequence[typing.Any]) -> None: + super().__init__() + + self.encodings = encodings + self.values = values + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(encodings={self.encodings!r}, values={self.values!r})" + + def _as_multiline_string_header_(self) -> str: + return "group" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for encoding, value in zip(self.encodings, self.values): + yield from advanced_repr.as_multiline_string(value, prefix=f"type {encoding!r}: ") + + +class TypedValue(TypedGroup): + """Special case of :class:`TypedGroup` for groups that contain only a single value. + + This class provides convenient properties for accessing the group's single type encoding and value, + as well as cleaner string representations. + Single-value groups are very common, + so this improves usability and readability in many cases. + """ + + @property + def encoding(self) -> bytes: + return self.encodings[0] + + @property + def value(self) -> typing.Any: + return self.values[0] + + def __init__(self, encoding: bytes, value: typing.Any) -> None: + super().__init__([encoding], [value]) + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(encoding={self.encoding!r}, value={self.value!r})" + + def _as_multiline_string_(self) -> typing.Iterable[str]: + yield from advanced_repr.as_multiline_string(self.value, prefix=f"type {self.encoding!r}: ") + + +class Array(advanced_repr.AsMultilineStringBase): + """Representation of a primitive C array stored in a typedstream.""" + + detect_backreferences = False + + elements: typing.Sequence[typing.Any] + + def __init__(self, elements: typing.Sequence[typing.Any]) -> None: + super().__init__() + + self.elements = elements + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}({self.elements!r})" + + def _as_multiline_string_header_(self) -> str: + if isinstance(self.elements, bytes): + return f"array, {len(self.elements)} bytes: {self.elements!r}" + else: + return f"array, {len(self.elements)} elements" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + if not isinstance(self.elements, bytes): # Byte arrays are displayed entirely in the header + for element in self.elements: + yield from advanced_repr.as_multiline_string(element) + + +class Class(object): + """Information about a class as it is stored at the start of objects in a typedstream.""" + + name: bytes + version: int + superclass: typing.Optional["Class"] + + def __init__(self, name: bytes, version: int, superclass: typing.Optional["Class"]) -> None: + super().__init__() + + self.name = name + self.version = version + self.superclass = superclass + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(name={self.name!r}, version={self.version!r}, superclass={self.superclass!r})" + + def __str__(self) -> str: + rep = f"{self.name.decode('ascii', errors='backslashreplace')} v{self.version}" + + if self.superclass is not None: + rep += f", extends {self.superclass}" + + return rep + + +class GenericArchivedObject(advanced_repr.AsMultilineStringBase): + """Representation of a generic object as it is stored in a typedstream. + + This class is only used for archived objects whose class is not known. + Objects of known classes are represented as instances of custom Python classes instead. + If an object's class is not known, + but one of its superclasses is, + then the known part of the object is represented as that known class + and only the remaining contents are stored in the generic format. + """ + + clazz: Class + super_object: "typing.Optional[KnownArchivedObject]" + contents: typing.List[TypedGroup] + + def __init__(self, clazz: Class, super_object: "typing.Optional[KnownArchivedObject]", contents: typing.List[TypedGroup]) -> None: + super().__init__() + + self.clazz = clazz + self.super_object = super_object + self.contents = contents + + def _allows_extra_data_(self) -> bool: + return True + + def _add_extra_field_(self, field: TypedGroup) -> None: + self.contents.append(field) + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(clazz={self.clazz!r}, super_object={self.super_object!r}, contents={self.contents!r})" + + def _as_multiline_string_header_(self) -> str: + header = f"object of class {self.clazz}" + if self.super_object is None and not self.contents: + header += ", no contents" + return header + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + if self.super_object is not None: + yield from advanced_repr.as_multiline_string(self.super_object, prefix="super object: ") + for value in self.contents: + yield from advanced_repr.as_multiline_string(value) + + +# False positive from flake8-bugbear, see: +# https://github.com/PyCQA/flake8-bugbear/issues/280 +class _KnownArchivedClass(abc.ABCMeta): # noqa: B024 + """Metaclass for :class:`KnownArchivedObject`.""" + + def __instancecheck__(self, instance: typing.Any) -> bool: + """Adds a special case for :class:`GenericArchivedObject`: + if its :attr:`~GenericArchivedObject.super_object` is an instance of this class, + then the entire :class:`GenericArchivedObject` is also considered an instance of this class. + + This simplifies isinstance checks in unarchiving methods + where objects might have an unknown concrete class with a known superclass. + """ + + return super().__instancecheck__(instance) or (isinstance(instance, GenericArchivedObject) and isinstance(instance.super_object, self)) + + +class KnownArchivedObject(metaclass=_KnownArchivedClass): + # archived_name is set by __init_subclass__ on each subclass. + archived_name: typing.ClassVar[bytes] + + @classmethod + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + # Set archived_name only if it hasn't already been set manually. + # Have to check directly in __dict__ instead of with try/except AttributeError or hasattr, + # because otherwise the archived_name from superclasses would be detected + # even archived_name on the class itself hasn't been set manually. + if "archived_name" not in cls.__dict__: + cls.archived_name = cls.__name__.encode("ascii") + + # Ditto for init_from_unarchiver. + if "init_from_unarchiver" not in cls.__dict__: + base_cls_unchecked = cls.__bases__[0] + if not issubclass(base_cls_unchecked, KnownArchivedObject): + raise TypeError(f"The first base class of an archived class must be KnownArchivedObject or a subclass of it (found {base_cls_unchecked})") + + # Workaround for https://github.com/python/mypy/issues/2608 - + # the check above narrows the type of base_cls_unchecked, + # but mypy doesn't pass the narrowed type into closures. + # So as a workaround assign the type-narrowed value to a new variable, + # which always has the specific type and isn't narrowed using a check, + # so mypy recognizes its type inside the closure as well. + base_cls = base_cls_unchecked + + # Provide a default init_from_unarchiver implementation + # that checks that the superclass in the archived class information matches the one in Python, + # calls init_from_unarchiver in the superclass (if there is one), + # and finally calls the class's own _init_from_unarchiver_ implementation. + def init_from_unarchiver(self: KnownArchivedObject, unarchiver: Unarchiver, archived_class: Class) -> None: + if base_cls == KnownArchivedObject: + # This is the root class (for archiving purposes) in the Python hierarchy. + # Ensure that the same is true for the archived class. + if archived_class.superclass is not None: + raise ValueError(f"Class {archived_class.name!r} should have no superclass, but unexpectedly has one in the typedstream: {archived_class}") + else: + # This class has a superclass (for archiving purposes) in the Python hierarchy. + # Ensure that the archived class also has one and that the names match. + if archived_class.superclass is None: + raise ValueError(f"Class {archived_class.name!r} should have superclass {base_cls.archived_name!r}, but has no superclass in the typedstream") + elif archived_class.superclass.name != base_cls.archived_name: + raise ValueError(f"Class {archived_class.name!r} should have superclass {base_cls.archived_name!r}, but has a different superclass in the typedstream: {archived_class}") + + base_cls.init_from_unarchiver(self, unarchiver, archived_class.superclass) + + # Ensure that the class defines its own _init_from_unarchiver_ + # and doesn't just inherit the superclass's implementation, + # because that would result in the superclass's implementation being called more than once. + # It also enforces that every class checks its own version number, + # even if it doesn't have any data other than that belonging to the superclass + # (because in another version it might have data of its own). + if "_init_from_unarchiver_" not in cls.__dict__: + raise ValueError("Every KnownArchivedObject must define its own _init_from_unarchiver_ implementation - inheriting it from the superclass is not allowed") + + cls._init_from_unarchiver_(self, unarchiver, archived_class.version) + + cls.init_from_unarchiver = init_from_unarchiver # type: ignore # mypy doesn't want you to assign to methods (it's fine here, our replacement has an identical signature) + + @abc.abstractmethod + def _init_from_unarchiver_(self, unarchiver: "Unarchiver", class_version: int) -> None: + """Initialize ``self`` by reading archived data from a typedstream. + + This method must be implemented in *every* class that inherits (directly or indirectly) from KnownArchivedObject. + Inheriting the implementation of a superclass is not allowed. + This is to ensure that every class checks its version number. + + Implementations of this method should only read data belonging to the class itself. + They shouldn't read any data belonging to the class's superclasses (if any), + and they shouldn't manually call the superclass's :func:`_init_from_unarchiver_` implementation. + The internals of :class:`KnownArchivedObject` + ensure that all classes in the superclass chain have their :func:`_init_from_unarchiver_` implementations called, + with the appropriate arguments and in the correct order + (superclasses before their subclasses). + + :param unarchiver: The unarchiver from which to read archived data. + :param class_version: The version of the class that archived the data. + A change in the class version number normally indicates that the data format has changed, + so implementations should check that the version number has the expected value + (or one of multiple expected values, + if there are multiple known versions of the data format) + and raise an exception otherwise. + """ + + raise NotImplementedError() + + # An override of init_from_unarchiver is defined by __init_subclass__ on each subclass. + # It can also be overridden manually in subclasses, + # in case the default implementation isn't suitable, + # for example if there is archived data belonging to the subclass before that belonging to the superclasses. + # In that case the implementation needs to manually perform all checks that would be performed by the automatic implementation, + # like checking the superclass name in the archived class information. + def init_from_unarchiver(self, unarchiver: "Unarchiver", archived_class: Class) -> None: + # Raise something other than NotImplementedError - this method normally doesn't need to be implemented manually by the user. + # (PyCharm for example warns when a subclass doesn't override a method that raises NotImplementedError.) + raise AssertionError("This implementation should never be called. It should have been overridden automatically by __init_subclass__.") + + def _allows_extra_data_(self) -> bool: + return False + + def _add_extra_field_(self, field: TypedGroup) -> None: + raise TypeError(f"{_object_class_name(self)} does not allow extra data at the end of the object") + + def __repr__(self) -> str: + class_name = _object_class_name(self) + if KnownArchivedObject in type(self).__bases__: + # This is an instance of a root class (e. g. NSObject, Object), + # which generally contains no interesting data of its own, + # so omit the ellipsis here. + return f"<{class_name}>" + else: + return f"<{class_name} ...>" + + +archived_classes_by_name: typing.Dict[bytes, typing.Type[KnownArchivedObject]] = {} + + +def register_archived_class(python_class: typing.Type[KnownArchivedObject]) -> None: + archived_classes_by_name[python_class.archived_name] = python_class + + +_KAO = typing.TypeVar("_KAO", bound=KnownArchivedObject) + + +def archived_class(python_class: typing.Type[_KAO]) -> typing.Type[_KAO]: + register_archived_class(python_class) + return python_class + + +def lookup_archived_class(archived_class: Class) -> typing.Tuple[typing.Type[KnownArchivedObject], Class]: + """Try to find the Python class corresponding to the given archived class. + + If the class cannot be found, + this function automatically tries to look up the superclass instead, + continuing recursively until a known class is encountered. + + :param archived_class: The archived class to look up. + :return: A tuple of the found Python class and its corresponding archived class. + The latter may be different from the ``archived_class`` parameter + if the exact class was not found, + but one of its superclasses was. + :raises LookupError: If no Python class could be found for any class in the superclass chain. + """ + + found_superclass: typing.Optional[Class] = archived_class + while found_superclass is not None: + try: + return archived_classes_by_name[found_superclass.name], found_superclass + except KeyError: + found_superclass = found_superclass.superclass + + raise LookupError(f"No Python class has been registered for any class in the superclass chain: {archived_class}") + + +def instantiate_archived_class(archived_class: Class) -> typing.Tuple[typing.Union[GenericArchivedObject, KnownArchivedObject], typing.Optional[Class]]: + # Try to look up a known custom Python class for the archived class + # (or alternatively one of its superclasses). + python_class: typing.Optional[typing.Type[KnownArchivedObject]] + superclass: typing.Optional[Class] + try: + python_class, superclass = lookup_archived_class(archived_class) + except LookupError: + python_class = None + superclass = None + + if python_class is None: + # No Python class was found for any part of the superclass chain - + # create a generic object instead. + return GenericArchivedObject(archived_class, None, []), superclass + elif superclass != archived_class: + # No Python class was found that matches the archived class exactly, + # but one of its superclasses was found - + # create an instance of that, + # so that at least part of the object can be decoded properly. + # The fields that are not part of any known class + # are stored in a generic object. + return GenericArchivedObject(archived_class, python_class(), []), superclass + else: + # A Python class was found that corresponds exactly to the archived class, + # so create an instance of it. + return python_class(), superclass + + +class GenericStruct(advanced_repr.AsMultilineStringBase): + """Representation of a generic C struct value as it is stored in a typedstream. + + This class is only used for struct values whose struct type is not known. + Structs of known types are represented as instances of custom Python classes instead. + """ + + name: typing.Optional[bytes] + fields: typing.List[typing.Any] + + def __init__(self, name: typing.Optional[bytes], fields: typing.List[typing.Any]) -> None: + super().__init__() + + self.name = name + self.fields = fields + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(name={self.name!r}, fields={self.fields!r})" + + def _as_multiline_string_header_(self) -> str: + if self.name is None: + decoded_name = "(no name)" + else: + decoded_name = self.name.decode("ascii", errors="backslashreplace") + return f"struct {decoded_name}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for field_value in self.fields: + yield from advanced_repr.as_multiline_string(field_value) + + +_KS = typing.TypeVar("_KS", bound="KnownStruct") + + +class KnownStruct(object): + struct_name: typing.ClassVar[bytes] + field_encodings: typing.ClassVar[typing.Sequence[bytes]] + encoding: typing.ClassVar[bytes] + + def __init_subclass__(cls) -> None: + super().__init_subclass__() + + cls.encoding = encodings.build_struct_encoding(cls.struct_name, cls.field_encodings) + + +struct_classes_by_encoding: typing.Dict[bytes, typing.Type[KnownStruct]] = {} + + +def register_struct_class(python_class: typing.Type[KnownStruct]) -> None: + struct_classes_by_encoding[python_class.encoding] = python_class + + +def struct_class(python_class: typing.Type[_KS]) -> typing.Type[_KS]: + register_struct_class(python_class) + return python_class + + +def _class_name(cls: typing.Type[typing.Any]) -> str: + if issubclass(cls, KnownArchivedObject): + return cls.archived_name.decode("ascii", errors="backslashreplace") + elif issubclass(cls, KnownStruct) and cls.struct_name != b"?": + return cls.struct_name.decode("ascii", errors="backslashreplace") + else: + return cls.__name__ + + +def _object_class_name(obj: typing.Any) -> str: + if isinstance(obj, GenericArchivedObject): + return obj.clazz.name.decode("ascii", errors="backslashreplace") + elif isinstance(obj, GenericStruct) and obj.name is not None and obj.name != b"?": + return obj.name.decode("ascii", errors="backslashreplace") + else: + return _class_name(type(obj)) + + +# Placeholder for unset lookahead parameters. +# Cannot use None for this, +# because None is a valid event. +_NO_LOOKAHEAD = object() + + +class Unarchiver(typing.ContextManager["Unarchiver"]): + reader: stream.TypedStreamReader + _close_reader: bool + _lookahead: typing.Any + shared_object_table: typing.List[typing.Tuple[stream.ObjectReference.Type, typing.Any]] + + @classmethod + def from_data(cls, data: bytes) -> "Unarchiver": + """Create an unarchiver for the given typedstream data.""" + + return cls(stream.TypedStreamReader.from_data(data), close=True) + + @classmethod + def from_stream(cls, f: typing.BinaryIO, *, close: bool = False) -> "Unarchiver": + """Create an unarchiver for the typedstream data in the given byte stream. + + :param f: The byte stream from which to decode data. + :param close: Controls whether the raw stream should also be closed when :meth:`close` is called. + By default this is ``False`` and callers are expected to close the raw stream themselves after closing the :class:`Unarchiver`. + """ + + return cls(stream.TypedStreamReader(f, close=close), close=True) + + @classmethod + def open(cls, filename: typing.Union[str, bytes, os.PathLike]) -> "Unarchiver": + """Create an unarchiver for the typedstream file at the given path.""" + + return cls(stream.TypedStreamReader.open(filename), close=True) + + def __init__(self, reader: stream.TypedStreamReader, *, close: bool = False) -> None: + """Create an :class:`Unarchiver` that decodes data based on events from the given low-level :class:`~typedstream.archiving.TypedStreamReader`. + + :param reader: The low-level reader from which to read the typedstream events. + :param close: Controls whether the low-level reader should also be closed when :meth:`close` is called. + By default this is ``False`` and callers are expected to close the reader themselves after closing the :class:`Unarchiver`. + """ + + super().__init__() + + self.reader = reader + self._close_reader = close + self.shared_object_table = [] + + def __enter__(self) -> "Unarchiver": + return self + + def __exit__( + self, + exc_type: typing.Optional[typing.Type[BaseException]], + exc_val: typing.Optional[BaseException], + exc_tb: typing.Optional[types.TracebackType], + ) -> typing.Optional[bool]: + self.close() + return None + + def close(self) -> None: + """Close this :class:`Unarchiver`. + + If ``close=True`` was passed when this :class:`Unarchiver` was created, + the underlying :class:`~typedstream.archiving.TypedStreamReader`'s ``close`` method is called as well. + """ + + if self._close_reader: + self.reader.close() + + def _lookup_reference(self, ref: stream.ObjectReference) -> typing.Any: + ref_type, obj = self.shared_object_table[ref.number] + if ref.referenced_type != ref_type: + raise ValueError(f"Object reference type mismatch: reference should point to an object of type {ref.referenced_type.value}, but the referenced object number {ref.number} has type {ref_type.value}") + return obj + + def decode_any_untyped_value(self, expected_encoding: bytes) -> typing.Any: + first = next(self.reader) + + if first is None or isinstance(first, (int, float, bytes)): + return first + elif isinstance(first, stream.ObjectReference): + return self._lookup_reference(first) + elif isinstance(first, stream.CString): + self.shared_object_table.append((stream.ObjectReference.Type.C_STRING, first.contents)) + return first.contents + elif isinstance(first, stream.Atom): + return first.contents + elif isinstance(first, stream.Selector): + return first.name + elif isinstance(first, stream.SingleClass): + # Read the superclass chain until (and including) the terminating Nil or reference. + single_classes = [first] + next_class_event = next(self.reader) + while next_class_event is not None and not isinstance(next_class_event, stream.ObjectReference): + if not isinstance(next_class_event, stream.SingleClass): + raise ValueError(f"Expected SingleClass, ObjectReference, or None, not {type(next_class_event)}") + single_classes.append(next_class_event) + next_class_event = next(self.reader) + + # Resolve the possibly Nil superclass of the last literally stored class. + terminating_event = next_class_event + if terminating_event is None: + next_superclass = None + elif isinstance(terminating_event, stream.ObjectReference): + next_superclass = self._lookup_reference(terminating_event) + else: + raise AssertionError() + + # Convert the SingleClass events from the stream into Class objects with a superclass. + # (The terminating Nil or reference is not included in this list, + # so that it doesn't get an object number assigned.) + # This list is built up backwards, + # because of how the SingleClass objects are stored in the stream - + # each class is stored *before* its superclass, + # but each Class object can only be constructed *after* its superclass Class has been constructed/looked up. + # So we iterate over the SingleClass events in reverse order, + # and store the Class objects in reverse order of construction, + # so that in the end new_classes matches the order stored in the stream. + new_classes: typing.List[Class] = [] + for single_class in reversed(single_classes): + next_superclass = Class(single_class.name, single_class.version, next_superclass) + new_classes.insert(0, next_superclass) + + # Object numbers for classes are assigned in the same order as they are stored in the stream. + for new_class in new_classes: + self.shared_object_table.append((stream.ObjectReference.Type.CLASS, new_class)) + + return next_superclass + elif isinstance(first, stream.BeginObject): + # The object's number is assigned *before* its class information is read, + # but at this point we can't create the object yet + # (because we don't know its class), + # so insert a placeholder value for now. + # This placeholder value is only used to make object number assignments happen in the right order. + # It's never used when actually looking up a reference, + # because it's replaced immediately after the class information is fully read, + # and the class information can only contain references to other classes and not objects. + placeholder_index = len(self.shared_object_table) + self.shared_object_table.append((stream.ObjectReference.Type.OBJECT, None)) + + archived_class = self.decode_any_untyped_value(b"#") + if not isinstance(archived_class, Class): + raise ValueError(f"Object class must be a Class, not {type(archived_class)}") + + # Create the object. + obj, superclass = instantiate_archived_class(archived_class) + known_obj: typing.Optional[KnownArchivedObject] + if isinstance(obj, GenericArchivedObject): + known_obj = obj.super_object + else: + known_obj = obj + + # Now that the object is created, + # replace the placeholder in the shared object table with the real object. + self.shared_object_table[placeholder_index] = (stream.ObjectReference.Type.OBJECT, obj) + + if known_obj is not None: + assert superclass is not None + known_obj.init_from_unarchiver(self, superclass) + + next_event = next(self.reader) + if obj._allows_extra_data_(): + # At least part of the object is not known, + # so there may be extra trailing data + # that should be stored in the generic part of the object. + while not isinstance(next_event, stream.EndObject): + obj._add_extra_field_(self.decode_typed_values(_lookahead=next_event)) + next_event = next(self.reader) + else: + # The object's exact class is fully known, + # so there shouldn't be any extra data at the end of the object. + if not isinstance(next_event, stream.EndObject): + raise ValueError(f"Expected EndObject after fully known archived object, not {type(next_event)}") + + return obj + elif isinstance(first, stream.ByteArray): + return Array(first.data) + elif isinstance(first, stream.BeginArray): + _, expected_element_encoding = encodings.parse_array_encoding(expected_encoding) + array = Array([self.decode_any_untyped_value(expected_element_encoding) for _ in range(first.length)]) + + end = next(self.reader) + if not isinstance(end, stream.EndArray): + raise ValueError(f"Expected EndArray, not {type(end)}") + + return array + elif isinstance(first, stream.BeginStruct): + python_struct_class: typing.Optional[typing.Type[KnownStruct]] + try: + python_struct_class = struct_classes_by_encoding[expected_encoding] + except KeyError: + python_struct_class = None + _, expected_field_encodings = encodings.parse_struct_encoding(expected_encoding) + else: + expected_field_encodings = python_struct_class.field_encodings + + fields = [self.decode_any_untyped_value(expected) for expected in expected_field_encodings] + + end = next(self.reader) + if not isinstance(end, stream.EndStruct): + raise ValueError(f"Expected EndStruct, not {type(end)}") + + if python_struct_class is None: + return GenericStruct(first.name, fields) + else: + return python_struct_class(*fields) + else: + raise ValueError(f"Unexpected event at beginning of untyped value: {type(first)}") + + def decode_typed_values(self, _lookahead: typing.Any = _NO_LOOKAHEAD) -> TypedGroup: + """Decode a group of typed values from the typedstream. + + The number of values in the group and their types are read dynamically from the type information in the typedstream. + + There's no Objective-C equivalent for this method - + ``NSUnarchiver`` only supports decoding values whose types are known beforehand. + """ + + if _lookahead is _NO_LOOKAHEAD: + begin = next(self.reader) + else: + begin = _lookahead + + if not isinstance(begin, stream.BeginTypedValues): + raise ValueError(f"Expected BeginTypedValues, not {type(begin)}") + + ret: TypedGroup + if len(begin.encodings) == 1: + # Single typed values are quite common, + # so use a special subclass that's more convenient to use. + ret = TypedValue(begin.encodings[0], self.decode_any_untyped_value(begin.encodings[0])) + else: + ret = TypedGroup( + begin.encodings, + [self.decode_any_untyped_value(encoding) for encoding in begin.encodings], + ) + + end = next(self.reader) + if not isinstance(end, stream.EndTypedValues): + raise ValueError(f"Expected EndTypedValues, not {type(end)}") + + return ret + + def decode_values_of_types(self, *type_encodings: typing.Union[bytes, typing.Type[KnownArchivedObject]]) -> typing.Sequence[typing.Any]: + """Decode a group of typed values from the typedstream, + which must have the given type encodings. + + This method is roughly equivalent to the Objective-C method ``-[NSUnarchiver decodeValuesOfObjCTypes:]``. + + This method only supports decoding groups with known type encodings. + To decode values of unknown type or a group containing an unknown number of values, + use :func:`decode_typed_values`. + """ + + if not type_encodings: + raise TypeError("Expected at least one type encoding") + + expected_type_encodings = [] + for enc in type_encodings: + if isinstance(enc, type): + expected_type_encodings.append(b"@") + else: + expected_type_encodings.append(enc) + + group = self.decode_typed_values() + + if not encodings.all_encodings_match_expected(group.encodings, expected_type_encodings): + raise ValueError(f"Expected type encodings {expected_type_encodings}, but got type encodings {group.encodings} in stream") + + for enc, obj in zip(type_encodings, group.values): + if obj is not None and isinstance(enc, type) and not isinstance(obj, enc): + raise TypeError(f"Expected object of class {_class_name(enc)}, but got class {_object_class_name(obj)} in stream") + + return group.values + + def decode_value_of_type(self, type_encoding: typing.Union[bytes, typing.Type[KnownArchivedObject]]) -> typing.Any: + """Decode a single typed value from the typedstream, + which must have the given type encoding. + + This method is roughly equivalent to the Objective-C method ``-[NSUnarchiver decodeValueOfObjCType:at:]``. + + This method only supports decoding single values with a known type encoding. + To decode groups of more than one value, + use :func:`decode_values_of_types`. + To decode values of unknown type or a group containing an unknown number of values, + use :func:`decode_typed_values`. + """ + + (value,) = self.decode_values_of_types(type_encoding) + return value + + def decode_array(self, element_type_encoding: bytes, length: int) -> Array: + return self.decode_value_of_type(encodings.build_array_encoding(length, element_type_encoding)) + + def decode_data_object(self) -> bytes: + """Decode a data object from the typedstream. + + This method is equivalent to the Objective-C method ``-[NSUnarchiver decodeDataObject]``. + """ + + length = self.decode_value_of_type(b"i") + if length < 0: + raise ValueError(f"Data object length cannot be negative: {length}") + data_array = self.decode_array(b"c", length) + assert isinstance(data_array.elements, bytes) + return data_array.elements + + def decode_property_list(self) -> typing.Any: + """Decode a property list (in old binary plist format) from the typedstream. + + This method is equivalent to the Objective-C method ``-[NSUnarchiver decodePropertyList]``. + """ + + return old_binary_plist.deserialize(self.decode_data_object()) + + def decode_all(self) -> typing.Sequence[TypedGroup]: + """Decode the entire contents of the typedstream.""" + + contents = [] + + while True: + try: + lookahead = next(self.reader) + except StopIteration: + break + + contents.append(self.decode_typed_values(_lookahead=lookahead)) + + return contents + + def decode_single_root(self) -> typing.Any: + """Decode the single root value in this unarchiver's typedstream. + + :raise ValueError: If the stream doesn't contain exactly one root value. + """ + + values = self.decode_all() + + if not values: + raise ValueError("Archive contains no values") + elif len(values) > 1: + raise ValueError(f"Archive contains {len(values)} root values (expected exactly one root value)") + else: + (root_group,) = values + if not isinstance(root_group, TypedValue): + raise ValueError(f"Archive's root value is a group of {len(root_group.values)} values (expected exactly one root value)") + return root_group.value + + +def unarchive_from_stream(f: typing.BinaryIO) -> typing.Any: + """Unarchive the given binary data stream containing a single archived root value. + + :raise ValueError: If the stream doesn't contain exactly one root value. + """ + + with Unarchiver.from_stream(f) as unarchiver: + return unarchiver.decode_single_root() + + +def unarchive_from_data(data: bytes) -> typing.Any: + """Unarchive the given data containing a single archived root value. + + :raise ValueError: If the data doesn't contain exactly one root value. + """ + + with Unarchiver.from_data(data) as unarchiver: + return unarchiver.decode_single_root() + + +def unarchive_from_file(path: typing.Union[str, bytes, os.PathLike]) -> typing.Any: + """Unarchive the given file containing a single archived root value. + + :raise ValueError: If the file doesn't contain exactly one root value. + """ + + with Unarchiver.open(path) as unarchiver: + return unarchiver.decode_single_root() diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/encodings.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/encodings.py new file mode 100644 index 0000000..7783856 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/encodings.py @@ -0,0 +1,232 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import typing + + +__all__ = [ + "split_encodings", + "join_encodings", + "parse_array_encoding", + "build_array_encoding", + "parse_struct_encoding", + "build_struct_encoding", + "encoding_matches_expected", + "all_encodings_match_expected", +] + + +# Adapted from https://github.com/beeware/rubicon-objc/blob/v0.3.1/rubicon/objc/types.py#L127-L188 +# The type encoding syntax used in typedstreams is very similar, +# but not identical, +# to the one used by the Objective-C runtime. +# Some features are not used/supported in typedstreams, +# such as qualifiers, arbitrary pointers, object pointer class names, block pointers, etc. +# Typedstreams also use some type encoding characters that are not used by the Objective-C runtime, +# such as "+" for raw bytes and "%" for "atoms" (deduplicated/uniqued/interned C strings). +def _end_of_encoding(encoding: bytes, start: int) -> int: + """Find the end index of the encoding starting at index start. + + The encoding is not validated very extensively. + There are no guarantees what happens for invalid encodings; + an error may be raised, + or a bogus end index may be returned. + Callers are expected to check that the returned end index actually results in a valid encoding. + """ + + if start not in range(len(encoding)): + raise ValueError(f"Start index {start} not in range({len(encoding)})") + + paren_depth = 0 + + i = start + while i < len(encoding): + c = encoding[i:i+1] + if c in b"([{": + # Opening parenthesis of some type, wait for a corresponding closing paren. + # This doesn't check that the parenthesis *types* match + # (only the *number* of closing parens has to match). + paren_depth += 1 + i += 1 + elif paren_depth > 0: + if c in b")]}": + # Closing parentheses of some type. + paren_depth -= 1 + i += 1 + if paren_depth == 0: + # Final closing parenthesis, end of this encoding. + return i + else: + # All other encodings consist of exactly one character. + return i + 1 + + if paren_depth > 0: + raise ValueError(f"Incomplete encoding, missing {paren_depth} closing parentheses: {encoding!r}") + else: + raise ValueError(f"Incomplete encoding, reached end of string too early: {encoding!r}") + + +# Adapted from https://github.com/beeware/rubicon-objc/blob/v0.3.1/rubicon/objc/types.py#L430-L450 +def split_encodings(encodings: bytes) -> typing.Iterable[bytes]: + """Split apart multiple type encodings contained in a single encoding string.""" + + start = 0 + while start < len(encodings): + end = _end_of_encoding(encodings, start) + yield encodings[start:end] + start = end + + +def join_encodings(encodings: typing.Iterable[bytes]) -> bytes: + """Combine a sequence of type encodings into a single type encoding string. + + .. note:: + + This function currently doesn't perform any checking on its inputs + and is currently equivalent to ``b"".join(encodings)``, + but such checks may be added in the future. + All elements of ``encodings`` should be valid type encoding strings. + """ + + return b"".join(encodings) + + +def parse_array_encoding(array_encoding: bytes) -> typing.Tuple[int, bytes]: + """Parse an array type encoding into its length and element type encoding.""" + + if not array_encoding.startswith(b"["): + raise ValueError(f"Missing opening bracket in array type encoding: {array_encoding!r}") + if not array_encoding.endswith(b"]"): + raise ValueError(f"Missing closing bracket in array type encoding: {array_encoding!r}") + + i = 1 + while i < len(array_encoding) - 1: + if array_encoding[i] not in b"0123456789": + break + i += 1 + length_string, element_type_encoding = array_encoding[1:i], array_encoding[i:-1] + + if not length_string: + raise ValueError(f"Missing length in array type encoding: {array_encoding!r}") + if not element_type_encoding: + raise ValueError(f"Missing element type in array type encoding: {array_encoding!r}") + + return int(length_string.decode("ascii")), element_type_encoding + + +def build_array_encoding(length: int, element_type_encoding: bytes) -> bytes: + """Build an array type encoding from a length and an element type encoding. + + .. note:: + + This function currently doesn't perform any checking on ``element_type_encoding``, + but such checks may be added in the future. + ``element_type_encoding`` should always be a valid type encoding string. + """ + + if length < 0: + raise ValueError(f"Array length cannot be negative: {length}") + + length_string = str(length).encode("ascii") + return b"[" + length_string + element_type_encoding + b"]" + + +def parse_struct_encoding(struct_encoding: bytes) -> typing.Tuple[typing.Optional[bytes], typing.Sequence[bytes]]: + """Parse an array type encoding into its name and field type encodings.""" + + if not struct_encoding.startswith(b"{"): + raise ValueError(f"Missing opening brace in struct type encoding: {struct_encoding!r}") + if not struct_encoding.endswith(b"}"): + raise ValueError(f"Missing closing brace in struct type encoding: {struct_encoding!r}") + + try: + # Stop searching for the equals if an opening brace + # (i. e. the start of another structure type encoding) + # is reached. + # This is necessary to correctly handle struct types with no name that contain a struct type with a name, + # such as b"{{foo=ii}}" (an unnamed struct containing a struct named "foo" containing two integers). + try: + end = struct_encoding.index(b"{", 1) + except ValueError: + end = -1 + equals_pos = struct_encoding.index(b"=", 1, end) + except ValueError: + name = None + field_type_encoding_string = struct_encoding[1:-1] + else: + name = struct_encoding[1:equals_pos] + field_type_encoding_string = struct_encoding[equals_pos+1:-1] + + field_type_encodings = list(split_encodings(field_type_encoding_string)) + return name, field_type_encodings + + +def build_struct_encoding(name: typing.Optional[bytes], field_type_encodings: typing.Iterable[bytes]) -> bytes: + """Build a struct type encoding from a name and field type encodings. + + .. note:: + + This function currently doesn't perform any checking on ``field_type_encodings``, + but such checks may be added in the future. + All elements of ``field_type_encodings`` should be valid type encoding strings. + """ + + field_type_encoding_string = join_encodings(field_type_encodings) + if name is None: + return b"{" + field_type_encoding_string + b"}" + else: + return b"{" + name + b"=" + field_type_encoding_string + b"}" + + +def encoding_matches_expected(actual_encoding: bytes, expected_encoding: bytes) -> bool: + """Check whether ``actual_encoding`` matches ``expected_encoding``, + accounting for struct names in ``actual_encoding`` possibly being missing. + """ + + if actual_encoding.startswith(b"{") and expected_encoding.startswith(b"{"): + actual_name, actual_field_type_encodings = parse_struct_encoding(actual_encoding) + expected_name, expected_field_type_encodings = parse_struct_encoding(expected_encoding) + return ( + (actual_name in {None, b"?"} or actual_name == expected_name) + and all_encodings_match_expected(actual_field_type_encodings, expected_field_type_encodings) + ) + elif actual_encoding.startswith(b"[") and expected_encoding.startswith(b"["): + actual_length, actual_element_type_encoding = parse_array_encoding(actual_encoding) + expected_length, expected_element_type_encoding = parse_array_encoding(expected_encoding) + return ( + actual_length == expected_length + and encoding_matches_expected(actual_element_type_encoding, expected_element_type_encoding) + ) + else: + return actual_encoding == expected_encoding + + +def all_encodings_match_expected(actual_encodings: typing.Sequence[bytes], expected_encodings: typing.Sequence[bytes]) -> bool: + """Check whether all of ``actual_encodings`` match ``expected_encodings``, + accounting for struct names in ``actual_encodings`` possibly being missing. + + If ``actual_encodings`` and ``expected_encodings`` don't have the same length, + they are considered to be not matching. + """ + + return ( + len(actual_encodings) == len(expected_encodings) + and all( + encoding_matches_expected(actual, expected) + for actual, expected in zip(actual_encodings, expected_encodings) + ) + ) diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/old_binary_plist.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/old_binary_plist.py new file mode 100644 index 0000000..0014438 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/old_binary_plist.py @@ -0,0 +1,185 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +"""Implementation of an old binary property list format, +apparently originally from NeXTSTEP. +This format is *not* the same as the modern Mac OS X/macOS binary property list format, +which starts with the signature string ``bplist00`` and has a completely different structure. + +This format supports only the following data types: + +* ``nil`` (mapped to ``None``) +* ``NSData`` (mapped to ``bytes``) +* ``NSString`` (mapped to ``str``), stored in either of the following encodings: + + * UTF-16 with BOM. Both big-endian and little-endian byte order are allowed. Mac OS X/macOS always outputs this encoding. + * The NeXTSTEP 8-bit character set. Mac OS X/macOS never outputs this encoding, but supports reading it. + +* ``NSArray`` (mapped to ``list``), which can contain any supported data type as elements +* ``NSDictionary`` (mapped to ``dict``), which uses strings as keys and can contain any supported data type as values + +We care about this format because it's used in Apple's implementation of -[NSArchiver encodePropertyList:] and -[NSUnarchiver decodePropertyList], +which are in turn used by some AppKit classes, +such as NSFont. + +This format is also used by the Foundation classes NSSerializer and NSDeserializer. +These classes have been deprecated since Mac OS X 10.2, +and their header () was removed from the Mac OS X SDK some time between Mac OS X 10.4 and 10.7. +However, as of macOS 10.14, +these classes are still present and usable in the Foundation framework at runtime. + +There is extremely little documentation on this format or the APIs that use it, +so this implementation is almost entirely based on examining the output of the relevant NSArchiver and NSSerializer methods. +Another deserializer implementation for this format can be found in the Darling project's Foundation implementation: +https://github.com/darlinghq/darling-foundation/blob/d3fe108d9d72e1ff4320129604bdb3de979ec82e/src/NSDeserializer.m +""" + + +import io +import typing + + +__all__ = [ + "deserialize_from_stream", + "deserialize", +] + + +# Unicode mapping of the NeXTSTEP 8-bit character set. +# This mapping was created by taking a byte string containing all bytes from 0x01 through 0xfd (inclusive) +# and decoding it using the macOS Foundation framework as NSNEXTSTEPStringEncoding: +# This can be done from Python using rubicon-objc: +# objc.py_from_ns(NSString.alloc().initWithBytes(bytes(range(1, 254)), length=253, encoding=2)) +# See also https://en.wikipedia.org/wiki/NeXT_character_set, +# although the mapping on the Wikipedia page doesn't exactly match the one used by macOS. +# The Wikipedia table indicates that character codes 0x60 and 0x27 correspond to opening and closing curly quotes (‘’), +# which is also documented in the NeXTSTEP 3.3 developer documentation linked from the Wikipedia page. +# However, macOS instead maps these codes to backtick/grave accent and straight quote (`'), +# which matches their meanings in plain 7-bit ASCII. +# For compatibility with macOS, +# we use the latter mapping. +_NEXTSTEP_8_BIT_CHARACTER_MAP = ( + '\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\x0c\r\x0e\x0f' + '\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f' + ' !"#$%&\'()*+,-./' + '0123456789:;<=>?' + '@ABCDEFGHIJKLMNO' + 'PQRSTUVWXYZ[\\]^_' + '`abcdefghijklmno' + 'pqrstuvwxyz{|}~\x7f' + '\xa0ÀÁÂÃÄÅÇÈÉÊËÌÍÎÏ' + 'ÐÑÒÓÔÕÖÙÚÛÜÝÞµ×÷' + '©¡¢£⁄¥ƒ§¤’“«‹›fifl' + '®–†‡·¦¶•‚„”»…‰¬¿' + '¹ˋ´ˆ˜¯˘˙¨²˚¸³˝˛ˇ' + '—±¼½¾àáâãäåçèéêë' + 'ìÆíªîïðñŁØŒºòóôõ' + 'öæùúûıüýłøœßþÿ' # The last two bytes (0xfe and 0xff) are unassigned. +) + + +def _read_exact(stream: typing.BinaryIO, byte_count: int) -> bytes: + """Read ``byte_count`` bytes from ``stream`` and raise an exception if too few bytes are read + (i. e. if EOF was hit prematurely). + """ + + data = stream.read(byte_count) + if len(data) != byte_count: + raise ValueError(f"Attempted to read {byte_count} bytes of data, but only got {len(data)} bytes") + return data + + +def deserialize_from_stream(stream: typing.BinaryIO) -> typing.Any: + """Deserialize an old binary plist from the given stream. + + This function stops and returns once the plist's root value has been fully read. + It doesn't check that the stream has been fully consumed. + Consider using :func:`deserialize` instead if you don't expect there to be any further data after the plist. + """ + + type_number = int.from_bytes(_read_exact(stream, 4), "little") + if type_number in {4, 5, 6}: + # Byte-length-prefixed data/string + data_length = int.from_bytes(_read_exact(stream, 4), "little") + data = _read_exact(stream, data_length) + align_padding = _read_exact(stream, (4 - data_length % 4) % 4) + if align_padding != bytes(len(align_padding)): + raise ValueError(f"Alignment padding after string/data should be all zero bytes, but got {align_padding!r}") + + if type_number == 4: + # NSData + return data + elif type_number == 5: + # NSString in NeXTSTEP 8-bit encoding + return "".join(_NEXTSTEP_8_BIT_CHARACTER_MAP[byte] for byte in data) + elif type_number == 6: + # NSString in UTF-16 (with BOM) encoding + return data.decode("utf-16") + else: + raise AssertionError(f"Unhandled type number: {type_number}") + elif type_number in {2, 7}: + element_count = int.from_bytes(_read_exact(stream, 4), "little") + + if type_number == 7: + keys = [] + for _ in range(element_count): + key = deserialize_from_stream(stream) + if not isinstance(key, str): + raise TypeError(f"Old plist dictionary key must be a string, not {type(key)}") + keys.append(key) + + value_lengths = [] + for _ in range(element_count): + value_lengths.append(int.from_bytes(_read_exact(stream, 4), "little")) + + values = [] + pos_before = stream.tell() + for expected_length in value_lengths: + value = deserialize_from_stream(stream) + pos = stream.tell() + if pos - pos_before != expected_length: + raise ValueError(f"Expected value to be {expected_length} bytes long, but actual length is {pos - pos_before}") + values.append(value) + pos_before = pos + + if type_number == 2: + # NSArray + return values + elif type_number == 7: + # NSDictionary + return dict(zip(keys, values)) + else: + raise AssertionError(f"Unhandled type number: {type_number}") + elif type_number == 8: + # nil + return None + else: + raise ValueError(f"Unknown/invalid type number: {type_number}") + + +def deserialize(data: bytes) -> typing.Any: + """Deserialize the given old binary plist data. + + This function checks that there is no remaining unused data after the end of the plist data. + """ + + f = io.BytesIO(data) + plist = deserialize_from_stream(f) + remaining = len(data) - f.tell() + if remaining != 0: + raise ValueError(f"There are {remaining} bytes of data after the end of the plist") + return plist diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/stream.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/stream.py new file mode 100644 index 0000000..2444db5 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/stream.py @@ -0,0 +1,1004 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import enum +import io +import os +import struct +import sys +import types +import typing + +from . import encodings + +if sys.version_info < (3, 8): + from typing_extensions import Literal +else: + from typing import Literal + + +__all__ = [ + "STREAMER_VERSION_OLD_NEXTSTEP", + "STREAMER_VERSION_CURRENT", + "SYSTEM_VERSION_NEXTSTEP_082", + "SYSTEM_VERSION_NEXTSTEP_083", + "SYSTEM_VERSION_NEXTSTEP_090", + "SYSTEM_VERSION_NEXTSTEP_0900", + "SYSTEM_VERSION_NEXTSTEP_0901", + "SYSTEM_VERSION_NEXTSTEP_0905", + "SYSTEM_VERSION_NEXTSTEP_0930", + "SYSTEM_VERSION_MAC_OS_X", + "InvalidTypedStreamError", + "BeginTypedValues", + "EndTypedValues", + "ObjectReference", + "Atom", + "Selector", + "CString", + "SingleClass", + "BeginObject", + "EndObject", + "ByteArray", + "BeginArray", + "EndArray", + "BeginStruct", + "EndStruct", + "ReadEvent", + "TypedStreamReader", +] + + +_FLOAT_STRUCTS_BY_BYTE_ORDER = { + "big": struct.Struct(">f"), + "little": struct.Struct("d"), + "little": struct.Struct(" int: + """Decode a reference number (as stored in a typedstream) to a regular zero-based index.""" + + return encoded - _FIRST_REFERENCE_NUMBER + + +class InvalidTypedStreamError(Exception): + """Raised by :class:`TypedStreamReader` if the typedstream data is invalid or doesn't match the expected structure.""" + + +class BeginTypedValues(object): + """Marks the beginning of a group of values prefixed by a type encoding string.""" + + encodings: typing.Sequence[bytes] + + def __init__(self, encodings: typing.Sequence[bytes]) -> None: + super().__init__() + + self.encodings = encodings + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}({self.encodings!r})" + + def __str__(self) -> str: + return f"begin typed values (types {self.encodings!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BeginTypedValues): + return NotImplemented + + return self.encodings == other.encodings + + +class EndTypedValues(object): + """Marks the end of a group of values prefixed by a type encoding string. + + This event is provided for convenience and doesn't correspond to any data in the typedstream. + """ + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}()" + + def __str__(self) -> str: + return "end typed values" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, EndTypedValues): + return NotImplemented + + return True + + +class ObjectReference(object): + """A reference to a previously read object.""" + + class Type(enum.Enum): + """Describes what type of object a reference refers to.""" + + C_STRING = "C string" + CLASS = "class" + OBJECT = "object" + + referenced_type: "ObjectReference.Type" + number: int + + def __init__(self, referenced_type: "ObjectReference.Type", number: int) -> None: + super().__init__() + + self.referenced_type = referenced_type + self.number = number + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}({self.referenced_type}, {self.number!r})" + + def __str__(self) -> str: + return f"" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ObjectReference): + return NotImplemented + + return self.referenced_type == other.referenced_type and self.number == other.number + + +class Atom(object): + """A NeXTSTEP atom (NXAtom), i. e. a shared/deduplicated C string. + + In (Objective-)C on NeXTSTEP, + atoms are immutable C strings that have been deduplicated so that two atoms with the same content always have the same address. + This allows checking two atoms for equality by just comparing the pointers/addresses, + instead of having to compare their contents. + Other than that, + atoms behave like regular C strings. + + Mac OS X/macOS no longer supports atoms and throws an exception when attempting to decode them using ``NSUnarchiver``. + + This is a thin wrapper around a plain :class:`bytes` object. + The wrapper class is used to distinguish atoms from untyped bytes. + """ + + contents: typing.Optional[bytes] + + def __init__(self, contents: typing.Optional[bytes]) -> None: + super().__init__() + + self.contents = contents + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}({self.contents!r})" + + def __str__(self) -> str: + return f"atom: {self.contents!r}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Atom): + return NotImplemented + + return self.contents == other.contents + + +class Selector(object): + """An Objective-C selector. + + This is a thin wrapper around a plain :class:`bytes` object. + The wrapper class is used to distinguish selector values from untyped bytes. + """ + + name: typing.Optional[bytes] + + def __init__(self, name: typing.Optional[bytes]) -> None: + super().__init__() + + self.name = name + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}({self.name!r})" + + def __str__(self) -> str: + return f"selector: {self.name!r}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, Selector): + return NotImplemented + + return self.name == other.name + + +class CString(object): + """Information about a C string as it is stored in a typedstream. + + This is a thin wrapper around a plain :class:`bytes` object. + The wrapper class is used to distinguish typed C string values from untyped bytes. + """ + + contents: bytes + + def __init__(self, contents: bytes) -> None: + super().__init__() + + self.contents = contents + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}({self.contents!r})" + + def __str__(self) -> str: + return f"C string: {self.contents!r}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, CString): + return NotImplemented + + return self.contents == other.contents + + +class SingleClass(object): + """Information about a class (name and version), + stored literally in a chain of superclasses in a typedstream. + + A class in a typedstream can be stored literally, as a reference, or be ``Nil``. + A literally stored class is always followed by information about its superclass. + If the superclass information is also stored literally, + it is again followed by information about its superclass. + This chain continues until a class is reached that has been stored before + (in which case it is stored as a reference) + or a root class is reached + (in which case the superclass is ``Nil``). + + The beginning and end of such a chain of superclasses are not marked explicitly in a typedstream, + and no events are generated when a superclass chain begins or ends. + A superclass chain begins implicitly when a literally stored class is encountered + (if no chain is already in progress), + and the chain ends after the first non-literal (i. e. reference or ``Nil``) class. + """ + + name: bytes + version: int + + def __init__(self, name: bytes, version: int) -> None: + super().__init__() + + self.name = name + self.version = version + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(name={self.name!r}, version={self.version})" + + def __str__(self) -> str: + return f"class {self.name.decode('ascii', errors='backslashreplace')} v{self.version}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, SingleClass): + return NotImplemented + + return self.name == other.name and self.version == other.version + + +class BeginObject(object): + """Marks the beginning of a literally stored object. + + This event is followed by information about the object's class, + stored as a chain of class information (see :class:`SingleClass`). + This class chain is followed by an arbitrary number of type-prefixed value groups, + which represent the object's contents. + The object ends when an :class:`EndObject` is encountered where the next value group would start. + """ + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}()" + + def __str__(self) -> str: + return "begin literal object" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BeginObject): + return NotImplemented + + return True + + +class EndObject(object): + """Marks the end of a literally stored object.""" + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}()" + + def __str__(self) -> str: + return "end literal object" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, EndObject): + return NotImplemented + + return True + + +class ByteArray(object): + """Represents an array of bytes (signed or unsigned char). + + For performance and simplicity, + such arrays are read all at once and represented as a single event, + instead of generating one event per element as for other array element types. + """ + + element_encoding: bytes + data: bytes + + def __init__(self, element_encoding: bytes, data: bytes) -> None: + super().__init__() + + self.element_encoding = element_encoding + self.data = data + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(element_encoding={self.element_encoding!r}, data={self.data!r})" + + def __str__(self) -> str: + return f"byte array (element type {self.element_encoding!r}): {self.data!r}" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ByteArray): + return NotImplemented + + return self.element_encoding == other.element_encoding and self.data == other.data + + +class BeginArray(object): + """Marks the beginning of an array. + + This event is provided for convenience and doesn't directly correspond to data in the typedstream. + The array length and element type information provided in this event actually comes from the arrays's type encoding. + + This event is followed by the element values, + which are not explicitly type-prefixed, + as they all have the type specified in the array type encoding. + The end of the array is not marked in the typedstream data, + as it can be determined based on the length and element type, + but for convenience, + an :class:`EndArray` element is generated after the last array element. + + This event is *not* generated for arrays of bytes (signed or unsigned char) - + such arrays are represented as single :class:`ByteArray` events instead. + """ + + element_encoding: bytes + length: int + + def __init__(self, element_encoding: bytes, length: int) -> None: + super().__init__() + + self.element_encoding = element_encoding + self.length = length + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(element_encoding={self.element_encoding!r}, length={self.length!r})" + + def __str__(self) -> str: + return f"begin array (element type {self.element_encoding!r}, length {self.length})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BeginArray): + return NotImplemented + + return self.element_encoding == other.element_encoding and self.length == other.length + + +class EndArray(object): + """Marks the end of an array. + + This event is provided for convenience and doesn't correspond to any data in the typedstream. + """ + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}()" + + def __str__(self) -> str: + return "end array" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, EndArray): + return NotImplemented + + return True + + +class BeginStruct(object): + """Marks the beginning of a struct. + + This event is provided for convenience and doesn't directly correspond to data in the typedstream. + The struct name and field type information provided in this event actually comes from the struct's type encoding. + + This event is followed by the field values, + which are not explicitly type-prefixed (unlike in objects), + as their types are specified in the struct type encoding. + The end of the struct is not marked in the typedstream data, + as it can be determined based on the type information, + but for convenience, + an :class:`EndStruct` element is generated after the last struct field. + """ + + name: typing.Optional[bytes] + field_encodings: typing.Sequence[bytes] + + def __init__(self, name: typing.Optional[bytes], field_encodings: typing.Sequence[bytes]) -> None: + super().__init__() + + self.name = name + self.field_encodings = field_encodings + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}(name={self.name!r}, field_encodings={self.field_encodings!r})" + + def __str__(self) -> str: + if self.name is None: + decoded_name = "(no name)" + else: + decoded_name = self.name.decode("ascii", errors="backslashreplace") + return f"begin struct {decoded_name} (field types {self.field_encodings!r})" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, BeginStruct): + return NotImplemented + + return self.name == other.name and self.field_encodings == other.field_encodings + + +class EndStruct(object): + """Marks the end of a struct. + + This event is provided for convenience and doesn't correspond to any data in the typedstream. + """ + + def __repr__(self) -> str: + return f"{type(self).__module__}.{type(self).__qualname__}()" + + def __str__(self) -> str: + return "end struct" + + def __eq__(self, other: object) -> bool: + if not isinstance(other, EndStruct): + return NotImplemented + + return True + + +ReadEvent = typing.Optional[typing.Union[BeginTypedValues, EndTypedValues, int, float, ObjectReference, CString, Atom, Selector, bytes, SingleClass, BeginObject, EndObject, ByteArray, BeginArray, EndArray, BeginStruct, EndStruct]] + + +class TypedStreamReader(typing.ContextManager["TypedStreamReader"], typing.Iterator[ReadEvent]): + """Reads typedstream data from a raw byte stream.""" + + _EOF_MESSAGE: typing.ClassVar[str] = "End of typedstream reached" + + _close_stream: bool + _stream: typing.BinaryIO + + shared_string_table: typing.List[bytes] + + streamer_version: int + byte_order: Literal["little", "big"] + system_version: int + + _events_iterator: typing.Iterator[ReadEvent] + + @classmethod + def from_data(cls, data: bytes) -> "TypedStreamReader": + """Create a reader for the given typedstream data.""" + + return cls(io.BytesIO(data), close=True) + + @classmethod + def open(cls, filename: typing.Union[str, bytes, os.PathLike]) -> "TypedStreamReader": + """Open the typedstream file at the given path.""" + + return cls(open(filename, "rb"), close=True) + + def __init__(self, stream: typing.BinaryIO, *, close: bool = False) -> None: + """Create a :class:`TypedStreamReader` that reads data from the given raw byte stream. + + :param stream: The raw byte stream from which to read the typedstream data. + :param close: Controls whether the raw stream should also be closed when :meth:`close` is called. + By default this is ``False`` and callers are expected to close the raw stream themselves after closing the :class:`TypedStreamReader`. + """ + + super().__init__() + + self._close_stream = close + self._stream = stream + + self.shared_string_table = [] + + try: + self._read_header() + self._events_iterator = self._read_all_values() + except BaseException: + self.close() + raise + + def close(self) -> None: + """Close this :class:`TypedStreamReader`. + + If ``close=True`` was passed when this :class:`TypedStreamReader` was created, the underlying raw stream's ``close`` method is called as well. + """ + + if self._close_stream: + self._stream.close() + + def __enter__(self) -> "TypedStreamReader": + return self + + def __exit__( + self, + exc_type: typing.Optional[typing.Type[BaseException]], + exc_val: typing.Optional[BaseException], + exc_tb: typing.Optional[types.TracebackType], + ) -> typing.Optional[bool]: + self.close() + return None + + def __repr__(self) -> str: + return f"<{type(self).__module__}.{type(self).__qualname__} at {id(self):#x}: streamer version {self.streamer_version}, byte order {self.byte_order}, system version {self.system_version}>" + + def __iter__(self) -> typing.Iterator[ReadEvent]: + return self + + def __next__(self) -> ReadEvent: + return next(self._events_iterator) + + def _read_exact(self, byte_count: int) -> bytes: + """Read byte_count bytes from the raw stream and raise an exception if too few bytes are read + (i. e. if EOF was hit prematurely). + """ + + data = self._stream.read(byte_count) + if len(data) != byte_count: + raise InvalidTypedStreamError(f"Attempted to read {byte_count} bytes of data, but only got {len(data)} bytes") + return data + + def _read_head_byte(self, head: typing.Optional[int] = None) -> int: + """Read a head byte. + + :param head: If ``None``, the head byte is read normally from the stream. + Otherwise, the passed-in head byte is returned and no read is performed. + This parameter is provided to simplify a common pattern in this class's internal methods, + where methods that need to read a head byte + can alternatively accept an already read head byte as a parameter + and skip the read operation. + This mechanism is used to allow a limited form of lookahead for the head byte, + which is needed to parse string and object references and to detect end-of-object markers. + :return: The read or passed in head byte. + """ + + if head is None: + head = int.from_bytes(self._read_exact(1), self.byte_order, signed=True) + return head + + def _read_integer(self, head: typing.Optional[int] = None, *, signed: bool) -> int: + """Read a low-level integer value. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :param signed: Whether to treat the integer as signed or unsigned. + :return: The decoded integer value. + """ + + head = self._read_head_byte(head) + if head not in _TAG_RANGE: + if signed: + return head + else: + return head & 0xff + elif head == _TAG_INTEGER_2: + return int.from_bytes(self._read_exact(2), self.byte_order, signed=signed) + elif head == _TAG_INTEGER_4: + return int.from_bytes(self._read_exact(4), self.byte_order, signed=signed) + else: + raise InvalidTypedStreamError(f"Invalid head tag in this context: {head} ({head & 0xff:#x})") + + def _read_header(self) -> None: + """Read the typedstream file header (streamer version, signature/byte order indicator, system version). + + This is called only once, + as part of :meth:`__init__`. + """ + + (self.streamer_version, signature_length) = self._read_exact(2) + + if self.streamer_version < STREAMER_VERSION_OLD_NEXTSTEP or self.streamer_version > STREAMER_VERSION_CURRENT: + raise InvalidTypedStreamError(f"Invalid streamer version: {self.streamer_version}") + elif self.streamer_version == STREAMER_VERSION_OLD_NEXTSTEP: + raise InvalidTypedStreamError(f"Old NeXTSTEP streamer version ({self.streamer_version}) not supported (yet?)") + + if signature_length != _SIGNATURE_LENGTH: + raise InvalidTypedStreamError(f"The signature string must be exactly {_SIGNATURE_LENGTH} bytes long, not {signature_length}") + + signature = self._read_exact(signature_length) + try: + self.byte_order = _SIGNATURE_TO_BYTE_ORDER_MAP[signature] + except KeyError: + raise InvalidTypedStreamError(f"Invalid signature string: {signature!r}") + + self.system_version = self._read_integer(signed=False) + + def _read_float(self, head: typing.Optional[int] = None) -> float: + """Read a low-level single-precision float value. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: The decoded float value. + """ + + head = self._read_head_byte(head) + if head == _TAG_FLOATING_POINT: + struc = _FLOAT_STRUCTS_BY_BYTE_ORDER[self.byte_order] + (v,) = struc.unpack(self._read_exact(struc.size)) + return v + else: + return float(self._read_integer(head, signed=True)) + + def _read_double(self, head: typing.Optional[int] = None) -> float: + """Read a low-level double-precision float value. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: The decoded double value. + """ + + head = self._read_head_byte(head) + if head == _TAG_FLOATING_POINT: + struc = _DOUBLE_STRUCTS_BY_BYTE_ORDER[self.byte_order] + (v,) = struc.unpack(self._read_exact(struc.size)) + return v + else: + return float(self._read_integer(head, signed=True)) + + def _read_unshared_string(self, head: typing.Optional[int] = None) -> typing.Optional[bytes]: + """Read a low-level string value. + + Strings in typedstreams have no specificed encoding, + so the string data is returned as raw :class:`bytes`. + (In practice, they usually consist of printable ASCII characters.) + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: The read string data, which may be ``nil``/``None``. + """ + + head = self._read_head_byte(head) + if head == _TAG_NIL: + return None + else: + length = self._read_integer(head, signed=False) + return self._read_exact(length) + + def _read_shared_string(self, head: typing.Optional[int] = None) -> typing.Optional[bytes]: + """Read a low-level shared string value. + + A shared string value may either be stored literally (as an unshared string) + or as a reference to a previous literally stored shared string. + Literal shared strings are appended to the :attr:`shared_string_table` after they are read, + so that they can be referenced by later non-literal shared strings. + This happens transparently to the caller - + in both cases the actual string data is returned. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: The read string data, which may be ``nil``/``None``. + """ + + head = self._read_head_byte(head) + if head == _TAG_NIL: + return None + elif head == _TAG_NEW: + string = self._read_unshared_string() + if string is None: + raise InvalidTypedStreamError("Literal shared string cannot contain a nil unshared string") + self.shared_string_table.append(string) + return string + else: + reference_number = self._read_integer(head, signed=True) + decoded = _decode_reference_number(reference_number) + return self.shared_string_table[decoded] + + def _read_object_reference(self, referenced_type: ObjectReference.Type, head: typing.Optional[int] = None) -> ObjectReference: + """Read an object reference value. + + Despite the name, + object references can't just refer to objects, + but also to classes or C strings. + The type of object that a reference refers to is always clear from context + and is not explicitly stored in the typedstream. + + :param referenced_type: The type of object that the reference refers to. + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: The read object reference. + """ + + reference_number = self._read_integer(head, signed=True) + return ObjectReference(referenced_type, _decode_reference_number(reference_number)) + + def _read_c_string(self, head: typing.Optional[int] = None) -> typing.Optional[typing.Union[CString, ObjectReference]]: + """Read a C string value. + + A C string value may either be stored literally + or as a reference to a previous literally stored C string value. + Literal C string values are returned as :class:`CString` objects. + C string values stored as references are returned as :class:`ObjectReference` objects + and are not automatically dereferenced. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: The read C string value or reference, which may be ``nil``/``None``. + """ + + head = self._read_head_byte(head) + if head == _TAG_NIL: + return None + elif head == _TAG_NEW: + string = self._read_shared_string() + if string is None: + raise InvalidTypedStreamError("Literal C string cannot contain a nil shared string") + # The typedstream format does not prevent C strings from containing zero bytes, + # though the NeXTSTEP/Apple writer never produces such strings, + # and the reader does not handle them properly. + if 0 in string: + raise InvalidTypedStreamError("C string value cannot contain zero bytes") + return CString(string) + else: + return self._read_object_reference(ObjectReference.Type.C_STRING, head) + + def _read_class(self, head: typing.Optional[int] = None) -> typing.Iterable[typing.Optional[typing.Union[SingleClass, ObjectReference]]]: + """Iteratively read a class object from the typedstream. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: An iterable of events representing the class object. + See :class:`SingleClass` for information about what events are generated when and what they mean. + """ + + head = self._read_head_byte(head) + while head == _TAG_NEW: + name = self._read_shared_string() + if name is None: + raise InvalidTypedStreamError("Class name cannot be nil") + version = self._read_integer(signed=True) + yield SingleClass(name, version) + head = self._read_head_byte() + + if head == _TAG_NIL: + yield None + else: + yield self._read_object_reference(ObjectReference.Type.CLASS, head) + + def _read_object(self, head: typing.Optional[int] = None) -> typing.Iterable[ReadEvent]: + """Iteratively read an object from the typedstream, + including all of its contents and the end of object marker. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: An iterable of events representing the object. + See :class:`BeginObject` and :class:`EndObject` for information about what events are generated when and what they mean. + """ + + head = self._read_head_byte(head) + if head == _TAG_NIL: + yield None + elif head == _TAG_NEW: + yield BeginObject() + yield from self._read_class() + next_head = self._read_head_byte() + while next_head != _TAG_END_OF_OBJECT: + yield from self._read_typed_values(next_head) + next_head = self._read_head_byte() + yield EndObject() + else: + yield self._read_object_reference(ObjectReference.Type.OBJECT, head) + + def _read_value_with_encoding(self, type_encoding: bytes, head: typing.Optional[int] = None) -> typing.Iterable[ReadEvent]: + """Iteratively read a single value with the type indicated by the given type encoding. + + The type encoding string must contain exactly one type + (although it may be a compound type like a struct or array). + Type encoding strings that might contain more than one value must first be split using :func:`_split_encodings`. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :return: An iterable of events representing the object. + Simple values are represented by single events, + but more complex values (classes, objects, arrays, structs) usually generate multiple events. + """ + + # Unlike other integer types, + # booleans and chars are always stored literally - + # the usual tags do not apply. + if type_encoding == b"B": + (val,) = self._read_exact(1) + if val == 0: + yield False + elif val == 1: + yield True + else: + raise InvalidTypedStreamError(f"Boolean value should be either 0 or 1, not {val}") + elif type_encoding == b"C": + yield int.from_bytes(self._read_exact(1), self.byte_order, signed=False) + elif type_encoding == b"c": + yield int.from_bytes(self._read_exact(1), self.byte_order, signed=True) + elif type_encoding in b"SILQ": + yield self._read_integer(head, signed=False) + elif type_encoding in b"silq": + yield self._read_integer(head, signed=True) + elif type_encoding == b"f": + yield self._read_float(head) + elif type_encoding == b"d": + yield self._read_double(head) + elif type_encoding == b"*": + yield self._read_c_string(head) + elif type_encoding == b"%": + yield Atom(self._read_shared_string(head)) + elif type_encoding == b":": + yield Selector(self._read_shared_string(head)) + elif type_encoding == b"+": + yield self._read_unshared_string(head) + elif type_encoding == b"#": + yield from self._read_class(head) + elif type_encoding == b"@": + yield from self._read_object(head) + elif type_encoding == b"!": + # "!" stands for an int-sized field that should be ignored when (un)archiving. + # The "!" *type* is stored in the typedstream when encoding and is expected to be present when decoding, + # but no actual data is written or read. + # Mac OS X/macOS supports encoding "!" using NSArchiver, + # but throws an exception when trying to decode it using NSUnarchiver. + yield None + elif type_encoding.startswith(b"["): + length, element_type_encoding = encodings.parse_array_encoding(type_encoding) + + if element_type_encoding in b"Cc": + # Special case for byte arrays for faster reading and a better parsed representation. + yield ByteArray(element_type_encoding, self._read_exact(length)) + else: + yield BeginArray(element_type_encoding, length) + for _ in range(length): + yield from self._read_value_with_encoding(element_type_encoding) + yield EndArray() + elif type_encoding.startswith(b"{"): + name, field_type_encodings = encodings.parse_struct_encoding(type_encoding) + yield BeginStruct(name, field_type_encodings) + for field_type_encoding in field_type_encodings: + yield from self._read_value_with_encoding(field_type_encoding) + yield EndStruct() + else: + raise InvalidTypedStreamError(f"Don't know how to read a value with type encoding {type_encoding!r}") + + def _read_typed_values(self, head: typing.Optional[int] = None, *, end_of_stream_ok: bool = False) -> typing.Iterable[ReadEvent]: + """Iteratively read the next group of typed values from the stream. + + The type encoding string is decoded to determine the type of the following values. + + :param head: An already read head byte to use, or ``None`` if the head byte should be read from the stream. + :param end_of_stream_ok: Whether reaching the end of the data stream is an acceptable condition. + If this method is called when the end of the stream is reached, + an :class:`EOFError` is raised if this parameter is true, + and an :class:`InvalidTypedStreamError` is raised if it is false. + If the end of the stream is reached in the middle of reading a value + (not right at the beginning), + the exception is always an :class:`InvalidTypedStreamError`, + regardless of the value of this parameter. + :return: An iterable of events representing the typed values. + See :class:`BeginTypedValues` and :class:`EndTypedValues` for information about what events are generated when and what they mean. + """ + + try: + head = self._read_head_byte(head) + except InvalidTypedStreamError: + if end_of_stream_ok: + raise EOFError(type(self)._EOF_MESSAGE) + else: + raise + + encoding_string = self._read_shared_string(head) + if encoding_string is None: + raise InvalidTypedStreamError("Encountered nil type encoding string") + elif not encoding_string: + raise InvalidTypedStreamError("Encountered empty type encoding string") + + type_encodings = list(encodings.split_encodings(encoding_string)) + yield BeginTypedValues(type_encodings) + for type_encoding in type_encodings: + yield from self._read_value_with_encoding(type_encoding) + yield EndTypedValues() + + def _read_all_values(self) -> typing.Iterator[ReadEvent]: + """Iteratively read all values in the typedstream. + + :return: An iterable of events representing the contents of the typedstream. + Top-level values in a typedstream are always prefixed with a type encoding. + See :class:`BeginTypedValues` and :class:`EndTypedValues` for information about what events are generated when and what they mean. + """ + + while True: + try: + yield from self._read_typed_values(end_of_stream_ok=True) + except EOFError as e: + # Make sure that the EOFError actually came from our code and not from some other IO code. + if tuple(e.args) == (type(self)._EOF_MESSAGE,): + return + else: + raise diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/__init__.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/__init__.py new file mode 100644 index 0000000..e70ed70 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/__init__.py @@ -0,0 +1,23 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +# It's important that these modules are imported automatically, +# so that their classes are registered with KnownArchivedObject and KnownStruct. +from . import nextstep # noqa: F401 +from . import core_graphics # noqa: F401 +from . import appkit # noqa: F401 +from . import foundation # noqa: F401 diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/_common.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/_common.py new file mode 100644 index 0000000..e15a956 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/_common.py @@ -0,0 +1,43 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import typing + +from .. import advanced_repr + + +class ArraySetBase(advanced_repr.AsMultilineStringBase): + detect_backreferences = False + + elements: typing.List[typing.Any] + + def _as_multiline_string_header_(self) -> str: + if not self.elements: + count_desc = "empty" + elif len(self.elements) == 1: + count_desc = "1 element" + else: + count_desc = f"{len(self.elements)} elements" + + return f"{type(self).__name__}, {count_desc}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for element in self.elements: + yield from advanced_repr.as_multiline_string(element) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.elements!r})" diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/appkit.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/appkit.py new file mode 100644 index 0000000..c68d950 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/appkit.py @@ -0,0 +1,1201 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import collections +import enum +import typing + +from .. import advanced_repr +from .. import archiving +from .. import stream +from . import foundation + + +def _object_class_name(obj: typing.Any) -> str: + if isinstance(obj, (NSClassSwapper, NSCustomObject)): + return obj.class_name + else: + return archiving._object_class_name(obj) + + +class NSBezierPathElement(enum.Enum): + move_to = 0 + line_to = 1 + curve_to = 2 + close_path = 3 + + +class NSLineCapStyle(enum.Enum): + butt = 0 + round = 1 + square = 2 + + +class NSLineJoinStyle(enum.Enum): + miter = 0 + round = 1 + bevel = 2 + + +class NSWindingRule(enum.Enum): + non_zero = 0 + even_odd = 1 + + +@archiving.archived_class +class NSBezierPath(foundation.NSObject, advanced_repr.AsMultilineStringBase): + elements: typing.List[typing.Tuple[NSBezierPathElement, foundation.NSPoint]] + winding_rule: NSWindingRule + line_cap_style: NSLineCapStyle + line_join_style: NSLineJoinStyle + line_width: float + miter_limit: float + flatness: float + line_dash: typing.Optional[typing.Tuple[float, typing.List[float]]] + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 524: + raise ValueError(f"Unsupported version: {class_version}") + + element_count = unarchiver.decode_value_of_type(b"i") + self.elements = [] + for _ in range(element_count): + element, x, y = unarchiver.decode_values_of_types(b"c", b"f", b"f") + self.elements.append((NSBezierPathElement(element), foundation.NSPoint(x, y))) + + ( + winding_rule, line_cap_style, line_join_style, + self.line_width, self.miter_limit, self.flatness, + line_dash_count, + ) = unarchiver.decode_values_of_types( + b"i", b"i", b"i", + b"f", b"f", b"f", + b"i", + ) + + self.winding_rule = NSWindingRule(winding_rule) + self.line_cap_style = NSLineCapStyle(line_cap_style) + self.line_join_style = NSLineJoinStyle(line_join_style) + + if line_dash_count > 0: + phase = unarchiver.decode_value_of_type(b"f") + pattern = [] + for _ in range(line_dash_count): + pattern.append(unarchiver.decode_value_of_type(b"f")) + + self.line_dash = (phase, pattern) + else: + self.line_dash = None + + def _as_multiline_string_header_(self) -> str: + return type(self).__name__ + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield f"winding rule: {self.winding_rule.name}" + yield f"line cap style: {self.line_cap_style.name}" + yield f"line join style: {self.line_join_style.name}" + yield f"line width: {self.line_width}" + yield f"miter limit: {self.miter_limit}" + yield f"flatness: {self.flatness}" + if self.line_dash is not None: + phase, pattern = self.line_dash + yield f"line dash: phase {phase}, pattern {pattern}" + + if self.elements: + yield f"{len(self.elements)} path elements:" + for element, point in self.elements: + yield f"\t{element.name} {point!s}" + else: + yield "no path elements" + + +@archiving.archived_class +class NSClassSwapper(foundation.NSObject, advanced_repr.AsMultilineStringBase): + class_name: str + template_class: archiving.Class + template: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 42: + raise ValueError(f"Unsupported version: {class_version}") + + class_name, self.template_class = unarchiver.decode_values_of_types(foundation.NSString, b"#") + self.class_name = class_name.value + + self.template, superclass = archiving.instantiate_archived_class(self.template_class) + known_obj: typing.Optional[archiving.KnownArchivedObject] + if isinstance(self.template, archiving.GenericArchivedObject): + known_obj = self.template.super_object + else: + known_obj = self.template + + if known_obj is not None: + assert superclass is not None + known_obj.init_from_unarchiver(unarchiver, superclass) + + def _allows_extra_data_(self) -> bool: + return self.template._allows_extra_data_() + + def _add_extra_field_(self, field: archiving.TypedGroup) -> None: + self.template._add_extra_field_(field) + + def _as_multiline_string_(self) -> typing.Iterable[str]: + yield from advanced_repr.as_multiline_string(self.template, prefix=f"{type(self).__name__}, class name {self.class_name!r}, template: ") + + +@archiving.archived_class +class NSColor(foundation.NSObject): + class Kind(enum.Enum): + CALIBRATED_RGBA = 1 + DEVICE_RGBA = 2 + CALIBRATED_WA = 3 + DEVICE_WA = 4 + DEVICE_CMYKA = 5 + NAMED = 6 + + class RGBAValue(object): + red: float + green: float + blue: float + alpha: float + + def __init__(self, red: float, green: float, blue: float, alpha: float) -> None: + super().__init__() + + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + + def __str__(self) -> str: + return f"{self.red}, {self.green}, {self.blue}, {self.alpha}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(red={self.red}, green={self.green}, blue={self.blue}, alpha={self.alpha})" + + class WAValue(object): + white: float + alpha: float + + def __init__(self, white: float, alpha: float) -> None: + super().__init__() + + self.white = white + self.alpha = alpha + + def __str__(self) -> str: + return f"{self.white}, {self.alpha}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(white={self.white}, alpha={self.alpha})" + + class CMYKAValue(object): + cyan: float + magenta: float + yellow: float + black: float + alpha: float + + def __init__(self, cyan: float, magenta: float, yellow: float, black: float, alpha: float) -> None: + super().__init__() + + self.cyan = cyan + self.magenta = magenta + self.yellow = yellow + self.black = black + self.alpha = alpha + + def __str__(self) -> str: + return f"{self.cyan}, {self.magenta}, {self.yellow}, {self.black}, {self.alpha}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(cyan={self.cyan}, magenta={self.magenta}, yellow={self.yellow}, black={self.black}, alpha={self.alpha})" + + class NamedValue(object): + group: str + name: str + color: "NSColor" + + def __init__(self, group: str, name: str, color: "NSColor") -> None: + super().__init__() + + self.group = group + self.name = name + self.color = color + + def __str__(self) -> str: + return f"group {self.group!r}, name {self.name!r}, color {self.color}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(group={self.group!r}, name={self.name!r}, color={self.color!r})" + + Value = typing.Union["NSColor.RGBAValue", "NSColor.WAValue", "NSColor.CMYKAValue", "NSColor.NamedValue"] + + kind: "NSColor.Kind" + value: "NSColor.Value" + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + self.kind = NSColor.Kind(unarchiver.decode_value_of_type(b"c")) + if self.kind in {NSColor.Kind.CALIBRATED_RGBA, NSColor.Kind.DEVICE_RGBA}: + red, green, blue, alpha = unarchiver.decode_values_of_types(b"f", b"f", b"f", b"f") + self.value = NSColor.RGBAValue(red, green, blue, alpha) + elif self.kind in {NSColor.Kind.CALIBRATED_WA, NSColor.Kind.DEVICE_WA}: + white, alpha = unarchiver.decode_values_of_types(b"f", b"f") + self.value = NSColor.WAValue(white, alpha) + elif self.kind == NSColor.Kind.DEVICE_CMYKA: + cyan, magenta, yellow, black, alpha = unarchiver.decode_values_of_types(b"f", b"f", b"f", b"f", b"f") + self.value = NSColor.CMYKAValue(cyan, magenta, yellow, black, alpha) + elif self.kind == NSColor.Kind.NAMED: + group, name, color = unarchiver.decode_values_of_types(foundation.NSString, foundation.NSString, NSColor) + self.value = NSColor.NamedValue(group.value, name.value, color) + else: + raise AssertionError(f"Unhandled NSColor kind: {self.kind}") + + def __str__(self) -> str: + return f"<{type(self).__name__} {self.kind.name}: {self.value}>" + + def __repr__(self) -> str: + return f"{type(self).__name__}(kind={self.kind.name}, value={self.value!r})" + + +@archiving.archived_class +class NSCustomObject(foundation.NSObject, advanced_repr.AsMultilineStringBase): + class_name: str + object: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 41: + raise ValueError(f"Unsuppored version: {class_version}") + + class_name, obj = unarchiver.decode_values_of_types(foundation.NSString, b"@") + self.class_name = class_name.value + self.object = obj + + def _as_multiline_string_(self) -> typing.Iterable[str]: + header = f"{type(self).__name__}, class {self.class_name}" + if self.object is None: + yield header + else: + yield from advanced_repr.prefix_lines( + advanced_repr.as_multiline_string(self.object), + first=header + ", object: ", + rest="\t", + ) + + def __repr__(self) -> str: + return f"{type(self).__name__}(class_name={self.class_name!r}, object={self.object!r})" + + +@archiving.archived_class +class NSCustomResource(foundation.NSObject): + class_name: str + resource_name: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 41: + raise ValueError(f"Unsuppored version: {class_version}") + + class_name, resource_name = unarchiver.decode_values_of_types(foundation.NSString, foundation.NSString) + self.class_name = class_name.value + self.resource_name = resource_name.value + + def __repr__(self) -> str: + return f"{type(self).__name__}(class_name={self.class_name!r}, resource_name={self.resource_name!r})" + + +@archiving.archived_class +class NSFont(foundation.NSObject): + name: str + size: float + flags_unknown: typing.Tuple[int, int, int, int] + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version not in {21, 30}: + raise ValueError(f"Unsupported version: {class_version}") + + name = unarchiver.decode_property_list() + if not isinstance(name, str): + raise TypeError(f"Font name must be a string, not {type(name)}") + self.name = name + self.size = unarchiver.decode_value_of_type(b"f") + self.flags_unknown = ( + unarchiver.decode_value_of_type(b"c"), + unarchiver.decode_value_of_type(b"c"), + unarchiver.decode_value_of_type(b"c"), + unarchiver.decode_value_of_type(b"c"), + ) + + def __repr__(self) -> str: + flags_repr = ", ".join([f"0x{flag:>02x}" for flag in self.flags_unknown]) + return f"{type(self).__name__}(name={self.name!r}, size={self.size!r}, flags_unknown=({flags_repr}))" + + +@archiving.archived_class +class NSIBObjectData(foundation.NSObject, advanced_repr.AsMultilineStringBase): + root: typing.Any + object_parents: "collections.OrderedDict[typing.Any, typing.Any]" + object_names: "collections.OrderedDict[typing.Any, typing.Optional[str]]" + unknown_set: typing.Any + connections: typing.List[typing.Any] + unknown_object: typing.Any + object_ids: "collections.OrderedDict[typing.Any, int]" + next_object_id: int + swapper_class_names: "collections.OrderedDict[typing.Any, str]" + target_framework: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 224: + raise ValueError(f"Unsupported version: {class_version}") + + self.root = unarchiver.decode_value_of_type(b"@") + + parents_count = unarchiver.decode_value_of_type(b"i") + self.object_parents = collections.OrderedDict() + for i in range(parents_count): + child, parent = unarchiver.decode_values_of_types(b"@", b"@") + if child in self.object_parents: + raise ValueError(f"Duplicate object parent entry {i} - this object already has a parent") + self.object_parents[child] = parent + + names_count = unarchiver.decode_value_of_type(b"i") + self.object_names = collections.OrderedDict() + for i in range(names_count): + obj, name = unarchiver.decode_values_of_types(b"@", foundation.NSString) + if obj in self.object_names: + raise ValueError(f"Duplicate object name entry {i} - this object already has a name") + + # Sometimes the name is nil. + # No idea if this has any special significance + # or if it behaves any different than having no name entry at all. + self.object_names[obj] = None if name is None else name.value + + self.unknown_set = unarchiver.decode_value_of_type(foundation.NSSet) + self.connections = unarchiver.decode_value_of_type(foundation.NSArray).elements + self.unknown_object = unarchiver.decode_value_of_type(b"@") + + oids_count = unarchiver.decode_value_of_type(b"i") + self.object_ids = collections.OrderedDict() + for i in range(oids_count): + obj, oid = unarchiver.decode_values_of_types(b"@", b"i") + if obj in self.object_ids: + raise ValueError(f"Duplicate object ID entry {i} - this object already has an ID") + self.object_ids[obj] = oid + + self.next_object_id = unarchiver.decode_value_of_type(b"i") + + swapper_class_names_count = unarchiver.decode_value_of_type(b"i") + self.swapper_class_names = collections.OrderedDict() + for _ in range(swapper_class_names_count): + obj, class_name = unarchiver.decode_values_of_types(b"@", foundation.NSString) + self.swapper_class_names[obj] = class_name.value + + self.target_framework = unarchiver.decode_value_of_type(foundation.NSString).value + + def _oid_repr(self, obj: typing.Any) -> str: + try: + oid = self.object_ids[obj] + except KeyError: + return "" + else: + return f"#{oid}" + + def _object_desc(self, obj: typing.Any) -> str: + if obj is None: + return "nil" + + desc = _object_class_name(obj) + + try: + name = self.object_names[obj] + except KeyError: + pass + else: + desc += f" {name!r}" + + return f"{self._oid_repr(obj)} ({desc})" + + def _render_tree(self, obj: typing.Any, children: typing.Mapping[typing.Any, typing.Any], seen: typing.Set[typing.Any]) -> typing.Iterable[str]: + yield self._object_desc(obj) + seen.add(obj) + for child in children.get(obj, []): + if child in seen: + yield f"\tWARNING: object appears more than once in tree: {self._object_desc(obj)}" + else: + for line in self._render_tree(child, children, seen): + yield "\t" + line + + def _as_multiline_string_header_(self) -> str: + return f"{type(self).__name__}, target framework {self.target_framework!r}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + children = collections.defaultdict(list) + for child, parent in self.object_parents.items(): + children[parent].append(child) + + for cs in children.values(): + cs.sort(key=lambda o: self.object_ids.get(o, 0)) + + seen_in_tree: typing.Set[typing.Any] = set() + yield from advanced_repr.prefix_lines( + self._render_tree(self.root, children, seen_in_tree), + first="object tree: ", + ) + + missed_parents = set(children) - seen_in_tree + if missed_parents: + yield "WARNING: one or more parent objects not reachable from root:" + for obj in missed_parents: + yield f"\t{self._object_desc(obj)} has children:" + for child in children[obj]: + yield f"\t\t{self._object_desc(child)}" + + missed_names = set(self.object_names) - seen_in_tree + if missed_names: + yield "WARNING: one or more named objects not reachable from root:" + for obj in missed_names: + yield f"\t{self._object_desc(obj)}" + + yield f"{len(self.connections)} connections:" + for connection in self.connections: + line = f"\t{self._object_desc(connection)}" + + if isinstance(connection, NSIBHelpConnector): + line += f": {self._object_desc(connection.object)} {connection.key!r} = {connection.value!r}" + elif isinstance(connection, NSNibConnector): + source_desc = self._object_desc(connection.source) + destination_desc = self._object_desc(connection.destination) + + if isinstance(connection, NSNibControlConnector): + line += f": {source_desc} -> [{destination_desc} {connection.label}]" + elif isinstance(connection, NSNibOutletConnector): + line += f": {source_desc}.{connection.label} = {destination_desc}" + else: + line += f": {source_desc} -> {connection.label!r} -> {destination_desc}" + + yield line + + missed_objects = set(self.object_ids) - seen_in_tree - set(self.connections) + if missed_objects: + yield "WARNING: one or more objects not reachable from root or connections:" + for obj in missed_objects: + yield f"\t{self._object_desc(obj)}" + + if self.swapper_class_names: + yield f"{len(self.swapper_class_names)} swapper class names:" + for obj, class_name in self.swapper_class_names.items(): + yield f"\t{self._object_desc(obj)}: {class_name!r}" + + yield f"{len(self.object_ids)} objects:" + for obj, oid in self.object_ids.items(): + oid_desc = f"#{oid}" + try: + name = self.object_names[obj] + except KeyError: + pass + else: + oid_desc += f" {name!r}" + + yield from advanced_repr.prefix_lines( + advanced_repr.as_multiline_string(obj), + first=f"\t{oid_desc}: ", + rest="\t", + ) + + yield f"next object ID: #{self.next_object_id}" + yield from advanced_repr.as_multiline_string(self.unknown_set, prefix="unknown set: ") + yield from advanced_repr.as_multiline_string(self.unknown_object, prefix="unknown object: ") + + def __repr__(self) -> str: + object_parents_repr = "{" + ", ".join(f"{self._oid_repr(child)}: {self._oid_repr(parent)}" for child, parent in self.object_parents.items()) + "}" + object_names_repr = "{" + ", ".join(f"{self._oid_repr(obj)}: {name!r}" for obj, name in self.object_names.items()) + "}" + connections_repr = "[" + ", ".join(f"{self._oid_repr(connection)}" for connection in self.connections) + "]" + object_ids_repr = "{" + ", ".join(f"<{_object_class_name(obj)}>: {oid}" for obj, oid in self.object_ids.items()) + "}" + + return f"<{type(self).__name__}: root={self._oid_repr(self.root)}, object_parents={object_parents_repr}, object_names={object_names_repr}, unknown_set={self.unknown_set!r}, connections={connections_repr}, unknown_object={self.unknown_object!r}, object_ids={object_ids_repr}, next_object_id={self.next_object_id}, target_framework={self.target_framework!r}>" + + +@archiving.archived_class +class NSIBHelpConnector(foundation.NSObject, advanced_repr.AsMultilineStringBase): + object: typing.Any + key: str + value: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 17: + raise ValueError(f"Unsupported version: {class_version}") + + self.object, key, value = unarchiver.decode_values_of_types(b"@", foundation.NSString, foundation.NSString) + self.key = key.value + self.value = value.value + + def _as_multiline_string_header_(self) -> str: + return f"{type(self).__name__}, key {self.key!r}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield f"value: {self.value!r}" + yield from advanced_repr.as_multiline_string(self.object, prefix="object: ") + + +@archiving.archived_class +class NSNibConnector(foundation.NSObject, advanced_repr.AsMultilineStringBase): + source: typing.Any + destination: typing.Any + label: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 17: + raise ValueError(f"Unsupported version: {class_version}") + + self.source, self.destination, label = unarchiver.decode_values_of_types(b"@", b"@", foundation.NSString) + self.label = label.value + + def _as_multiline_string_header_(self) -> str: + return f"{type(self).__name__}, label {self.label!r}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from advanced_repr.as_multiline_string(self.source, prefix="source: ") + yield from advanced_repr.as_multiline_string(self.destination, prefix="destination: ") + + +@archiving.archived_class +class NSNibControlConnector(NSNibConnector): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 207: + raise ValueError(f"Unsupported version: {class_version}") + + def _as_multiline_string_header_(self) -> str: + return f"{type(self).__name__} <{_object_class_name(self.source)}> -> -[{_object_class_name(self.destination)} {self.label}]" + + +@archiving.archived_class +class NSNibOutletConnector(NSNibConnector): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 207: + raise ValueError(f"Unsupported version: {class_version}") + + def _as_multiline_string_header_(self) -> str: + return f"{type(self).__name__} <{_object_class_name(self.source)}>.{self.label} = <{_object_class_name(self.destination)}>" + + +class NSControlStateValue(enum.Enum): + mixed = -1 + off = 0 + on = 1 + + +class NSEventModifierFlags(enum.IntFlag): + caps_lock = 1 << 16 + shift = 1 << 17 + control = 1 << 18 + option = 1 << 19 + command = 1 << 20 + numeric_pad = 1 << 21 + help = 1 << 22 + function = 1 << 23 + + device_independent_flags_mask = 0xffff0000 + + def __str__(self) -> str: + if self == 0: + return "(no modifiers)" + + flags = self + modifiers = [] + + for flag, name in _MODIFIER_KEY_NAMES.items(): + if flag in flags: + modifiers.append(name) + flags &= ~flag + + if flags: + # Render any remaining unknown flags as plain hex. + modifiers.append(f"({flags:#x})") + + return "+".join(modifiers) + + +_MODIFIER_KEY_NAMES = collections.OrderedDict([ + (NSEventModifierFlags.caps_lock, "CapsLock"), + (NSEventModifierFlags.shift, "Shift"), + (NSEventModifierFlags.control, "Ctrl"), + (NSEventModifierFlags.option, "Alt"), + (NSEventModifierFlags.command, "Cmd"), + (NSEventModifierFlags.numeric_pad, "(NumPad)"), + (NSEventModifierFlags.help, "(Help)"), + (NSEventModifierFlags.function, "(FKey)"), +]) + + +@archiving.archived_class +class NSMenuItem(foundation.NSObject, advanced_repr.AsMultilineStringBase): + menu: "NSMenu" + flags: int + title: str + key_equivalent: str + modifier_flags: NSEventModifierFlags + state: NSControlStateValue + on_state_image: typing.Any + off_state_image: typing.Any + mixed_state_image: typing.Any + action: stream.Selector + int_2: int + target: typing.Any + submenu: "typing.Optional[NSMenu]" + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version not in {505, 671}: + raise ValueError(f"Unsupported version: {class_version}") + + self.menu = unarchiver.decode_value_of_type(NSMenu) + ( + flags, title, key_equivalent, modifier_flags, + int_1, state, + obj_1, self.on_state_image, self.off_state_image, self.mixed_state_image, + self.action, self.int_2, obj_2, + ) = unarchiver.decode_values_of_types( + b"i", foundation.NSString, foundation.NSString, b"I", + b"I", b"i", + b"@", b"@", b"@", b"@", + b":", b"i", b"@", + ) + + self.flags = flags & 0xffffffff + self.title = title.value + self.key_equivalent = key_equivalent.value + self.modifier_flags = NSEventModifierFlags(modifier_flags) + self.state = NSControlStateValue(state) + + if int_1 != 0x7fffffff: + raise ValueError(f"Unknown int 1 is not 0x7fffffff: {int_1}") + if obj_1 is not None: + raise ValueError("Unknown object 1 is not nil") + if obj_2 is not None: + raise ValueError("Unknown object 2 is not nil") + + self.target = unarchiver.decode_value_of_type(b"@") + self.submenu = unarchiver.decode_value_of_type(NSMenu) + + def _as_multiline_string_header_(self) -> str: + header = f"{type(self).__name__} {self.title!r}" + if self.key_equivalent: + header += f" ({self.modifier_flags!s}+{self.key_equivalent!r})" + return header + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield f"in menu: <{_object_class_name(self.menu)} {self.menu.title!r}>" + + if self.flags != 0: + yield f"flags: 0x{self.flags:>08x}" + + if self.state != NSControlStateValue.off: + yield f"initial state: {self.state.name}" + + if not isinstance(self.on_state_image, NSCustomResource) or self.on_state_image.class_name != "NSImage" or self.on_state_image.resource_name != "NSMenuCheckmark": + yield from advanced_repr.as_multiline_string(self.on_state_image, prefix="on state image: ") + + if self.off_state_image is not None: + yield from advanced_repr.as_multiline_string(self.off_state_image, prefix="off state image: ") + + if not isinstance(self.mixed_state_image, NSCustomResource) or self.mixed_state_image.class_name != "NSImage" or self.mixed_state_image.resource_name != "NSMenuMixedState": + yield from advanced_repr.as_multiline_string(self.mixed_state_image, prefix="mixed state image: ") + + if self.action is not None: + yield f"action: {self.action}" + if self.int_2 != 0: + yield f"unknown int 2: {self.int_2}" + if self.target is not None: + yield f"target: <{_object_class_name(self.target)}>" + + if self.submenu is not None: + yield from advanced_repr.as_multiline_string(self.submenu, prefix="submenu: ") + + +@archiving.archived_class +class NSMenu(foundation.NSObject, advanced_repr.AsMultilineStringBase): + title: str + items: typing.List[NSMenuItem] + identifier: typing.Optional[str] + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 204: + raise ValueError(f"Unsupported version: {class_version}") + + unknown_int, title, items, identifier = unarchiver.decode_values_of_types(b"i", foundation.NSString, foundation.NSArray, foundation.NSString) + + if unknown_int != 0: + raise ValueError(f"Unknown int is not 0: {unknown_int}") + + self.title = title.value + + self.items = [] + for item in items.elements: + if not isinstance(item, NSMenuItem): + raise TypeError(f"NSMenu items must be instances of NSMenuItem, not {type(item).__name__}") + + self.items.append(item) + + if identifier is None: + self.identifier = None + else: + self.identifier = identifier.value + + def _as_multiline_string_header_(self) -> str: + header = f"{type(self).__name__} {self.title!r}" + + if self.identifier is not None: + header += f" ({self.identifier!r})" + + if not self.items: + header += ", no items" + elif len(self.items) == 1: + header += ", 1 item" + else: + header += f", {len(self.items)} items" + + return header + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for item in self.items: + yield from advanced_repr.as_multiline_string(item) + + +@archiving.archived_class +class NSCell(foundation.NSObject, advanced_repr.AsMultilineStringBase): + flags_unknown: typing.Tuple[int, int] + title_or_image: typing.Any + font: NSFont + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 65: + raise ValueError(f"Unsupported version: {class_version}") + + flags_1, flags_2 = unarchiver.decode_values_of_types(b"i", b"i") + self.flags_unknown = (flags_1 & 0xffffffff, flags_2 & 0xffffffff) + + self.title_or_image, self.font, obj_3, obj_4 = unarchiver.decode_values_of_types(b"@", NSFont, b"@", b"@") + if obj_3 is not None: + raise ValueError("Unknown object 3 is not nil") + if obj_4 is not None: + raise ValueError("Unknown object 4 is not nil") + + def _as_multiline_string_header_(self) -> str: + return type(self).__name__ + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield f"flags: (0x{self.flags_unknown[0]:>08x}, 0x{self.flags_unknown[1]:>08x})" + yield f"title/image: {self.title_or_image!r}" + yield f"font: {self.font!r}" + + +class NSImageAlignment(enum.Enum): + center = 0 + top = 1 + top_left = 2 + top_right = 3 + left = 4 + bottom = 5 + bottom_left = 6 + bottom_right = 7 + right = 8 + + +class NSImageFrameStyle(enum.Enum): + none = 0 + photo = 1 + gray_bezel = 2 + groove = 3 + button = 4 + + +class NSImageScaling(enum.Enum): + proportionally_down = 0 + axes_independently = 1 + none = 2 + proportionally_up_or_down = 3 + + +@archiving.archived_class +class NSImageCell(NSCell): + image_alignment: NSImageAlignment + image_scaling: NSImageScaling + image_frame_style: NSImageFrameStyle + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 41: + raise ValueError(f"Unsupported version: {class_version}") + + image_alignment, image_scaling, image_frame_style = unarchiver.decode_values_of_types(b"i", b"i", b"i") + self.image_alignment = NSImageAlignment(image_alignment) + self.image_scaling = NSImageScaling(image_scaling) + self.image_frame_style = NSImageFrameStyle(image_frame_style) + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + yield f"image alignment: {self.image_alignment.name}" + yield f"image scaling: {self.image_scaling.name}" + yield f"image frame style: {self.image_frame_style.name}" + + +@archiving.archived_class +class NSActionCell(NSCell): + tag: int + action: typing.Optional[stream.Selector] + target: typing.Any + control_view: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 17: + raise ValueError(f"Unsupported version: {class_version}") + + self.tag, self.action = unarchiver.decode_values_of_types(b"i", b":") + + self.target = unarchiver.decode_value_of_type(b"@") + + self.control_view = unarchiver.decode_value_of_type(b"@") + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + if self.tag != 0: + yield f"tag: {self.tag}" + + if self.action is not None: + yield f"action: {self.action!r}" + + if self.target is not None: + yield f"target: <{_object_class_name(self.target)}>" + + if self.control_view is None: + control_view_desc = "None" + else: + control_view_desc = f"<{_object_class_name(self.control_view)}>" + yield f"control view: {control_view_desc}" + + +class NSButtonType(enum.Enum): + momentary_light = 0 + push_on_push_off = 1 + toggle = 2 + switch = 3 + radio = 4 + momentary_change = 5 + on_off = 6 + momentary_push_in = 7 + accelerator = 8 + multi_level_accelerator = 9 + + +@archiving.archived_class +class NSButtonImageSource(foundation.NSObject): + resource_name: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 3: + raise ValueError(f"Unsupported version: {class_version}") + + self.resource_name = unarchiver.decode_value_of_type(foundation.NSString).value + + def __repr__(self) -> str: + return f"{type(self).__name__}(resource_name={self.resource_name!r})" + + +@archiving.archived_class +class NSButtonCell(NSActionCell): + shorts_unknown: typing.Tuple[int, int] + type: NSButtonType + type_flags: int + flags: int + key_equivalent: str + image_1: typing.Any + image_2_or_font: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 63: + raise ValueError(f"Unsupported version: {class_version}") + + ( + short_1, short_2, button_type, flags, + string_1, key_equivalent, self.image_1, self.image_2_or_font, unknown_object, + ) = unarchiver.decode_values_of_types( + b"s", b"s", b"i", b"i", + foundation.NSString, foundation.NSString, b"@", b"@", b"@", + ) + + self.shorts_unknown = (short_1, short_2) + if self.shorts_unknown not in {(200, 25), (400, 75)}: + raise ValueError(f"Unexpected value for unknown shorts: {self.shorts_unknown}") + + self.type = NSButtonType(button_type & 0xffffff) + self.type_flags = button_type & 0xff000000 + self.flags = flags & 0xffffffff + + if string_1 is not None and string_1.value: + raise ValueError(f"Unknown string 1 is not nil or empty: {string_1}") + + self.key_equivalent = key_equivalent.value + + if unknown_object is not None: + raise ValueError("Unknown object is not nil") + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + yield f"unknown shorts: {self.shorts_unknown!r}" + yield f"button type: {self.type.name}" + if self.type_flags != 0: + yield f"button type flags: 0x{self.type_flags:>08x}" + yield f"button flags: 0x{self.flags:>08x}" + + if self.key_equivalent: + yield f"key equivalent: {self.key_equivalent!r}" + if self.image_1 is not None: + yield from advanced_repr.as_multiline_string(self.image_1, prefix="image 1: ") + if self.image_2_or_font is not None: + yield from advanced_repr.as_multiline_string(self.image_2_or_font, prefix="image 2 or font: ") + + +@archiving.archived_class +class NSTextFieldCell(NSActionCell): + draws_background: bool + background_color: NSColor + text_color: NSColor + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version not in {61, 62}: + raise ValueError(f"Unsupported version: {class_version}") + + draws_background, self.background_color, self.text_color = unarchiver.decode_values_of_types(b"c", NSColor, NSColor) + + if draws_background == 0: + self.draws_background = False + elif draws_background == 1: + self.draws_background = True + else: + raise ValueError(f"Unexpected value for boolean: {draws_background}") + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + yield f"draws background: {self.draws_background}" + yield f"background color: {self.background_color}" + yield f"text color: {self.text_color}" + + +@archiving.archived_class +class NSComboBoxCell(NSTextFieldCell): + number_of_visible_items: int + values: typing.List[typing.Any] + combo_box: "NSView" + button_cell: NSButtonCell + table_view: "NSView" + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 2: + raise ValueError(f"Unsupported version: {class_version}") + + self.number_of_visible_items, bool_1, bool_2, bool_3 = unarchiver.decode_values_of_types(b"i", b"c", b"c", b"c") + + if bool_1 != 1: + raise ValueError(f"Unknown boolean 1 is not 1: {bool_1}") + if bool_2 != 1: + raise ValueError(f"Unknown boolean 2 is not 1: {bool_2}") + if bool_3 != 0: + raise ValueError(f"Unknown boolean 3 is not 0: {bool_3}") + + self.values = unarchiver.decode_value_of_type(foundation.NSArray).elements + + unknown_object = unarchiver.decode_value_of_type(b"@") + if unknown_object is not None: + raise ValueError("Unknown object is not nil") + + self.combo_box = unarchiver.decode_value_of_type(NSView) + self.button_cell = unarchiver.decode_value_of_type(NSButtonCell) + self.table_view = unarchiver.decode_value_of_type(NSView) + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + yield f"number of visible items: {self.number_of_visible_items}" + + if self.values: + if len(self.values) == 1: + yield "1 value:" + else: + yield f"{len(self.values)} values:" + + for value in self.values: + for line in advanced_repr.as_multiline_string(value): + yield "\t" + line + + yield f"combo box: <{_object_class_name(self.combo_box)}>" + yield from advanced_repr.as_multiline_string(self.button_cell, prefix="button cell: ") + yield from advanced_repr.as_multiline_string(self.table_view, prefix="table view: ") + + +@archiving.archived_class +class NSTableHeaderCell(NSTextFieldCell): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 28: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSResponder(foundation.NSObject, advanced_repr.AsMultilineStringBase): + next_responder: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + self.next_responder = unarchiver.decode_value_of_type(b"@") + + def _as_multiline_string_header_(self) -> str: + return type(self).__name__ + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + if self.next_responder is None: + next_responder_desc = "None" + else: + next_responder_desc = f"<{_object_class_name(self.next_responder)}>" + yield f"next responder: {next_responder_desc}" + + def __repr__(self) -> str: + if self.next_responder is None: + next_responder_desc = "None" + else: + next_responder_desc = f"<{_object_class_name(self.next_responder)}>" + return f"{type(self).__name__}(next_responder={next_responder_desc})" + + +@archiving.archived_class +class NSView(NSResponder): + flags: int + subviews: typing.List[typing.Any] + registered_dragged_types: typing.List[str] + frame: foundation.NSRect + bounds: foundation.NSRect + superview: typing.Any + content_view: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 41: + raise ValueError(f"Unsupported version: {class_version}") + + self.flags = unarchiver.decode_value_of_type(b"i") + + ( + subviews, obj2, obj3, registered_dragged_types, + frame_x, frame_y, frame_width, frame_height, + bounds_x, bounds_y, bounds_width, bounds_height, + ) = unarchiver.decode_values_of_types( + foundation.NSArray, b"@", b"@", foundation.NSSet, + b"f", b"f", b"f", b"f", + b"f", b"f", b"f", b"f", + ) + + if subviews is None: + self.subviews = [] + else: + self.subviews = subviews.elements + + if obj2 is not None: + raise ValueError("Unknown object 2 is not nil") + if obj3 is not None: + raise ValueError("Unknown object 3 is not nil") + + self.registered_dragged_types = [] + if registered_dragged_types is not None: + for tp in registered_dragged_types.elements: + if not isinstance(tp, foundation.NSString): + raise TypeError(f"NSView dragged types must be instances of NSString, not {type(tp).__name__}") + + self.registered_dragged_types.append(tp.value) + + self.frame = foundation.NSRect.make(frame_x, frame_y, frame_width, frame_height) + self.bounds = foundation.NSRect.make(bounds_x, bounds_y, bounds_width, bounds_height) + + self.superview = unarchiver.decode_value_of_type(b"@") + + obj6 = unarchiver.decode_value_of_type(b"@") + if obj6 is not None: + raise ValueError("Unknown object 6 is not nil") + self.content_view = unarchiver.decode_value_of_type(b"@") + obj8 = unarchiver.decode_value_of_type(b"@") + if obj8 is not None: + raise ValueError("Unknown object 8 is not nil") + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + yield f"flags: 0x{self.flags:>08x}" + + if self.subviews: + yield f"{len(self.subviews)} {'subview' if len(self.subviews) == 1 else 'subviews'}:" + for subview in self.subviews: + for line in advanced_repr.as_multiline_string(subview): + yield "\t" + line + + if self.registered_dragged_types: + yield f"{len(self.registered_dragged_types)} registered dragged types:" + for tp in self.registered_dragged_types: + yield f"\t{tp!r}" + + yield f"frame: {self.frame}" + yield f"bounds: {self.bounds}" + + if self.superview is None: + superview_desc = "None" + else: + superview_desc = f"<{_object_class_name(self.superview)}>" + yield f"superview: {superview_desc}" + + if self.content_view is not None: + yield f"content view: <{_object_class_name(self.content_view)}>" + + +@archiving.archived_class +class NSControl(NSView): + int_1: int + bool_1: bool + cell: typing.Optional[NSCell] + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 41: + raise ValueError(f"Unsupported version: {class_version}") + + self.int_1, bool_1, int_3, self.cell = unarchiver.decode_values_of_types(b"i", b"c", b"c", NSCell) + + if bool_1 == 0: + self.bool_1 = False + elif bool_1 == 1: + self.bool_1 = True + else: + raise ValueError(f"Unexpected value for boolean: {bool_1}") + + if int_3 != 0: + raise ValueError(f"Unknown int 3 is not 0: {int_3}") + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + yield from super()._as_multiline_string_body_() + + yield f"unknown int 1: {self.int_1}" + yield f"unknown boolean 1: {self.bool_1}" + yield from advanced_repr.as_multiline_string(self.cell, prefix="cell: ") diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/core_graphics.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/core_graphics.py new file mode 100644 index 0000000..e4731a1 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/core_graphics.py @@ -0,0 +1,112 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2022 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +from .. import archiving + + +@archiving.struct_class +class CGPoint(archiving.KnownStruct): + struct_name = b"CGPoint" + field_encodings = [b"d", b"d"] + + x: float + y: float + + def __init__(self, x: float, y: float) -> None: + super().__init__() + + self.x = x + self.y = y + + def __str__(self) -> str: + x = int(self.x) if int(self.x) == self.x else self.x + y = int(self.y) if int(self.y) == self.y else self.y + return f"{{{x}, {y}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(x={self.x!r}, y={self.y!r})" + + +@archiving.struct_class +class CGSize(archiving.KnownStruct): + struct_name = b"CGSize" + field_encodings = [b"d", b"d"] + + width: float + height: float + + def __init__(self, width: float, height: float) -> None: + super().__init__() + + self.width = width + self.height = height + + def __str__(self) -> str: + width = int(self.width) if int(self.width) == self.width else self.width + height = int(self.height) if int(self.height) == self.height else self.height + return f"{{{width}, {height}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(width={self.width!r}, height={self.height!r})" + + +@archiving.struct_class +class CGVector(archiving.KnownStruct): + struct_name = b"CGVector" + field_encodings = [b"d", b"d"] + + dx: float + dy: float + + def __init__(self, dx: float, dy: float) -> None: + super().__init__() + + self.dx = dx + self.dy = dy + + def __str__(self) -> str: + dx = int(self.dx) if int(self.dx) == self.dx else self.dx + dy = int(self.dy) if int(self.dy) == self.dy else self.dy + return f"{{{dx}, {dy}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(dx={self.dx!r}, dy={self.dy!r})" + + +@archiving.struct_class +class CGRect(archiving.KnownStruct): + struct_name = b"CGRect" + field_encodings = [CGPoint.encoding, CGSize.encoding] + + origin: CGPoint + size: CGSize + + def __init__(self, origin: CGPoint, size: CGSize) -> None: + super().__init__() + + self.origin = origin + self.size = size + + @classmethod + def make(cls, x: float, y: float, width: float, height: float) -> "CGRect": + return cls(CGPoint(x, y), CGSize(width, height)) + + def __str__(self) -> str: + return f"{{{self.origin}, {self.size}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(origin={self.origin!r}, size={self.size!r})" diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/foundation.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/foundation.py new file mode 100644 index 0000000..e86d8a7 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/foundation.py @@ -0,0 +1,305 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import collections +import datetime +import typing + +from .. import advanced_repr +from .. import archiving +from . import _common + + +@archiving.struct_class +class NSPoint(archiving.KnownStruct): + struct_name = b"_NSPoint" + field_encodings = [b"f", b"f"] + + x: float + y: float + + def __init__(self, x: float, y: float) -> None: + super().__init__() + + self.x = x + self.y = y + + def __str__(self) -> str: + x = int(self.x) if int(self.x) == self.x else self.x + y = int(self.y) if int(self.y) == self.y else self.y + return f"{{{x}, {y}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(x={self.x!r}, y={self.y!r})" + + +@archiving.struct_class +class NSSize(archiving.KnownStruct): + struct_name = b"_NSSize" + field_encodings = [b"f", b"f"] + + width: float + height: float + + def __init__(self, width: float, height: float) -> None: + super().__init__() + + self.width = width + self.height = height + + def __str__(self) -> str: + width = int(self.width) if int(self.width) == self.width else self.width + height = int(self.height) if int(self.height) == self.height else self.height + return f"{{{width}, {height}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(width={self.width!r}, height={self.height!r})" + + +@archiving.struct_class +class NSRect(archiving.KnownStruct): + struct_name = b"_NSRect" + field_encodings = [NSPoint.encoding, NSSize.encoding] + + origin: NSPoint + size: NSSize + + def __init__(self, origin: NSPoint, size: NSSize) -> None: + super().__init__() + + self.origin = origin + self.size = size + + @classmethod + def make(cls, x: float, y: float, width: float, height: float) -> "NSRect": + return cls(NSPoint(x, y), NSSize(width, height)) + + def __str__(self) -> str: + return f"{{{self.origin}, {self.size}}}" + + def __repr__(self) -> str: + return f"{type(self).__name__}(origin={self.origin!r}, size={self.size!r})" + + +@archiving.archived_class +class NSObject(archiving.KnownArchivedObject): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSData(NSObject): + data: bytes + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + self.data = unarchiver.decode_data_object() + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.data!r})" + + +@archiving.archived_class +class NSMutableData(NSData): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSDate(NSObject): + ABSOLUTE_REFERENCE_DATE: typing.ClassVar[datetime.datetime] = datetime.datetime(2001, 1, 1, tzinfo=datetime.timezone.utc) + + absolute_reference_date_offset: float + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + self.absolute_reference_date_offset = unarchiver.decode_value_of_type(b"d") + + @property + def value(self) -> datetime.datetime: + return type(self).ABSOLUTE_REFERENCE_DATE + datetime.timedelta(seconds=self.absolute_reference_date_offset) + + def __str__(self) -> str: + return f"<{type(self).__name__}: {self.value}>" + + def __repr__(self) -> str: + return f"{type(self).__name__}(absolute_reference_date_offset={self.absolute_reference_date_offset})" + + +@archiving.archived_class +class NSString(NSObject): + value: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 1: + raise ValueError(f"Unsupported version: {class_version}") + + self.value = unarchiver.decode_value_of_type(b"+").decode("utf-8") + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.value!r})" + + +@archiving.archived_class +class NSMutableString(NSString): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 1: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSURL(NSObject): + relative_to: "typing.Optional[NSURL]" + value: str + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + is_relative = unarchiver.decode_value_of_type(b"c") + if is_relative == 0: + self.relative_to = None + elif is_relative == 1: + self.relative_to = unarchiver.decode_value_of_type(NSURL) + else: + raise ValueError(f"Unexpected value for boolean: {is_relative}") + + self.value = unarchiver.decode_value_of_type(NSString).value + + def __repr__(self) -> str: + if self.relative_to is None: + return f"{type(self).__name__}({self.value!r})" + else: + return f"{type(self).__name__}(relative_to={self.relative_to!r}, value={self.value!r})" + + +@archiving.archived_class +class NSValue(NSObject, advanced_repr.AsMultilineStringBase): + detect_backreferences = False + + type_encoding: bytes + value: typing.Any + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + self.type_encoding = unarchiver.decode_value_of_type(b"*") + self.value = unarchiver.decode_value_of_type(self.type_encoding) + + def _as_multiline_string_(self) -> typing.Iterable[str]: + yield from advanced_repr.as_multiline_string(self.value, prefix=f"{type(self).__name__}, type {self.type_encoding!r}: ") + + def __repr__(self) -> str: + return f"{type(self).__name__}(type_encoding={self.type_encoding!r}, value={self.value!r})" + + +@archiving.archived_class +class NSNumber(NSValue): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSArray(NSObject, _common.ArraySetBase): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + count = unarchiver.decode_value_of_type(b"i") + if count < 0: + raise ValueError(f"NSArray element count cannot be negative: {count}") + self.elements = [] + for _ in range(count): + self.elements.append(unarchiver.decode_value_of_type(b"@")) + + +@archiving.archived_class +class NSMutableArray(NSArray): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSSet(NSObject, _common.ArraySetBase): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + count = unarchiver.decode_value_of_type(b"I") + self.elements = [] + for _ in range(count): + self.elements.append(unarchiver.decode_value_of_type(b"@")) + + +@archiving.archived_class +class NSMutableSet(NSSet): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class NSDictionary(NSObject, advanced_repr.AsMultilineStringBase): + detect_backreferences = False + + contents: "collections.OrderedDict[typing.Any, typing.Any]" + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + count = unarchiver.decode_value_of_type(b"i") + if count < 0: + raise ValueError(f"NSDictionary element count cannot be negative: {count}") + self.contents = collections.OrderedDict() + for _ in range(count): + key = unarchiver.decode_value_of_type(b"@") + value = unarchiver.decode_value_of_type(b"@") + self.contents[key] = value + + def _as_multiline_string_header_(self) -> str: + if not self.contents: + count_desc = "empty" + elif len(self.contents) == 1: + count_desc = "1 entry" + else: + count_desc = f"{len(self.contents)} entries" + + return f"{type(self).__name__}, {count_desc}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for key, value in self.contents.items(): + yield from advanced_repr.as_multiline_string(value, prefix=f"{key!r}: ") + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.contents!r})" + + +@archiving.archived_class +class NSMutableDictionary(NSDictionary): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") diff --git a/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/nextstep.py b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/nextstep.py new file mode 100644 index 0000000..e098c37 --- /dev/null +++ b/iMessage.indigoPlugin/Contents/Server Plugin/typedstream/types/nextstep.py @@ -0,0 +1,200 @@ +# This file is part of the python-typedstream library. +# Copyright (C) 2020 dgelessus +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . + + +import collections +import typing + +from .. import advanced_repr +from .. import archiving +from . import _common + + +@archiving.archived_class +class Object(archiving.KnownArchivedObject): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 0: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class List(Object, _common.ArraySetBase): + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version == 0: + _, count = unarchiver.decode_values_of_types(b"i", b"i") + if count < 0: + raise ValueError(f"List element count cannot be negative: {count}") + self.elements = list(unarchiver.decode_array(b"@", count).elements) + elif class_version == 1: + count = unarchiver.decode_value_of_type(b"i") + if count < 0: + raise ValueError(f"List element count cannot be negative: {count}") + + if count > 0: + self.elements = list(unarchiver.decode_array(b"@", count).elements) + else: + # If the list is empty, + # the array isn't stored at all. + self.elements = [] + else: + raise ValueError(f"Unsupported version: {class_version}") + + +@archiving.archived_class +class HashTable(Object, advanced_repr.AsMultilineStringBase): + detect_backreferences = False + + contents: "collections.OrderedDict[typing.Any, typing.Any]" + key_type_encoding: bytes + value_type_encoding: bytes + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version == 0: + string_type_encoding = b"*" + elif class_version == 1: + string_type_encoding = b"%" + else: + raise ValueError(f"Unsupported version: {class_version}") + + count, self.key_type_encoding, self.value_type_encoding = unarchiver.decode_values_of_types(b"i", string_type_encoding, string_type_encoding) + if count < 0: + raise ValueError(f"HashTable element count cannot be negative: {count}") + + self.contents = collections.OrderedDict() + for _ in range(count): + key = unarchiver.decode_value_of_type(self.key_type_encoding) + value = unarchiver.decode_value_of_type(self.value_type_encoding) + self.contents[key] = value + + def _as_multiline_string_header_(self) -> str: + if not self.contents: + count_desc = "empty" + elif len(self.contents) == 1: + count_desc = "1 entry" + else: + count_desc = f"{len(self.contents)} entries" + + return f"{type(self).__name__}, key/value types {self.key_type_encoding!r}/{self.value_type_encoding!r}, {count_desc}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for key, value in self.contents.items(): + yield from advanced_repr.as_multiline_string(value, prefix=f"{key!r}: ") + + def __repr__(self) -> str: + return f"{type(self).__name__}(key_type_encoding={self.key_type_encoding!r}, value_type_encoding={self.value_type_encoding!r}, contents={self.contents!r})" + + +@archiving.archived_class +class StreamTable(HashTable): + detect_backreferences = False + + class _UnarchivedContents(typing.Mapping[typing.Any, typing.Any]): + archived_contents: typing.Mapping[typing.Any, bytes] + + def __init__(self, archived_contents: typing.Mapping[typing.Any, bytes]) -> None: + super().__init__() + + self.archived_contents = archived_contents + + def __len__(self) -> int: + return len(self.archived_contents) + + def __iter__(self) -> typing.Iterator[typing.Any]: + return iter(self.archived_contents) + + def keys(self) -> typing.KeysView[typing.Any]: + return self.archived_contents.keys() + + def __getitem__(self, key: typing.Any) -> typing.Any: + return archiving.unarchive_from_data(self.archived_contents[key]) + + unarchived_contents: "collections.Mapping[typing.Any, typing.Any]" + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version != 1: + raise ValueError(f"Unsupported version: {class_version}") + + if self.value_type_encoding != b"!": + raise ValueError(f"StreamTable values must be ignored, not {self.value_type_encoding!r}") + + for key in self.contents: + assert self.contents[key] is None + key_again = unarchiver.decode_value_of_type(self.key_type_encoding) + if key != key_again: + raise ValueError(f"Expected to read value for key {key}, but found {key_again}") + self.contents[key] = unarchiver.decode_data_object() + + self.unarchived_contents = StreamTable._UnarchivedContents(self.contents) + + def _as_multiline_string_header_(self) -> str: + if not self.unarchived_contents: + count_desc = "empty" + elif len(self.unarchived_contents) == 1: + count_desc = "1 entry" + else: + count_desc = f"{len(self.unarchived_contents)} entries" + + return f"{type(self).__name__}, {count_desc}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for key, value in self.unarchived_contents.items(): + yield from advanced_repr.as_multiline_string(value, prefix=f"{key!r}: ") + + +@archiving.archived_class +class Storage(Object, advanced_repr.AsMultilineStringBase): + detect_backreferences = False + + element_type_encoding: bytes + element_size: int + elements: typing.List[typing.Any] + + def _init_from_unarchiver_(self, unarchiver: archiving.Unarchiver, class_version: int) -> None: + if class_version == 0: + self.element_type_encoding, self.element_size, _, count = unarchiver.decode_values_of_types(b"*", b"i", b"i", b"i") + if count < 0: + raise ValueError(f"Storage element count cannot be negative: {count}") + self.elements = list(unarchiver.decode_array(self.element_type_encoding, count).elements) + elif class_version == 1: + self.element_type_encoding, self.element_size, count = unarchiver.decode_values_of_types(b"%", b"i", b"i") + if count < 0: + raise ValueError(f"Storage element count cannot be negative: {count}") + + if count > 0: + self.elements = list(unarchiver.decode_array(self.element_type_encoding, count).elements) + else: + # If the Storage is empty, + # the array isn't stored at all. + self.elements = [] + else: + raise ValueError(f"Unsupported version: {class_version}") + + def _as_multiline_string_header_(self) -> str: + if not self.elements: + count_desc = "empty" + elif len(self.elements) == 1: + count_desc = "1 element" + else: + count_desc = f"{len(self.elements)} elements" + + return f"{type(self).__name__}, element type {self.element_type_encoding!r} ({self.element_size!r} bytes each), {count_desc}" + + def _as_multiline_string_body_(self) -> typing.Iterable[str]: + for element in self.elements: + yield from advanced_repr.as_multiline_string(element) + + def __repr__(self) -> str: + return f"{type(self).__name__}(element_type_encoding={self.element_type_encoding!r}, element_size={self.element_size!r}, elements={self.elements!r})"