diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..29f0411 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:community.chocolatey.org)", + "WebFetch(domain:github.com)" + ] + } +} diff --git a/poetry.lock b/poetry.lock index c531f96..72b7ead 100644 --- a/poetry.lock +++ b/poetry.lock @@ -308,6 +308,46 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2) ; sys_platform != \"win32\"", "winloop (>=0.5.0) ; sys_platform == \"win32\""] +[[package]] +name = "boto3" +version = "1.42.73" +description = "The AWS SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455"}, + {file = "boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae"}, +] + +[package.dependencies] +botocore = ">=1.42.73,<1.43.0" +jmespath = ">=0.7.1,<2.0.0" +s3transfer = ">=0.16.0,<0.17.0" + +[package.extras] +crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] + +[[package]] +name = "botocore" +version = "1.42.73" +description = "Low-level, data-driven core of boto 3." +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe"}, + {file = "botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c"}, +] + +[package.dependencies] +jmespath = ">=0.7.1,<2.0.0" +python-dateutil = ">=2.1,<3.0.0" +urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >= \"3.10\""} + +[package.extras] +crt = ["awscrt (==0.31.2)"] + [[package]] name = "cachetools" version = "7.0.5" @@ -1135,6 +1175,88 @@ test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "backports-zstd ; python_version < \"3.14\"", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas (<3.0.0)", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] tqdm = ["tqdm"] +[[package]] +name = "google-api-core" +version = "2.30.0" +description = "Google API client core library" +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "google_api_core-2.30.0-py3-none-any.whl", hash = "sha256:80be49ee937ff9aba0fd79a6eddfde35fe658b9953ab9b79c57dd7061afa8df5"}, + {file = "google_api_core-2.30.0.tar.gz", hash = "sha256:02edfa9fab31e17fc0befb5f161b3bf93c9096d99aed584625f38065c511ad9b"}, +] + +[package.dependencies] +google-auth = ">=2.14.1,<3.0.0" +googleapis-common-protos = ">=1.56.3,<2.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, +] +grpcio-status = [ + {version = ">=1.75.1,<2.0.0", optional = true, markers = "python_version >= \"3.14\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, +] +proto-plus = {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""} +protobuf = ">=4.25.8,<7.0.0" +requests = ">=2.20.0,<3.0.0" + +[package.extras] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio (>=1.75.1,<2.0.0) ; python_version >= \"3.14\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.75.1,<2.0.0) ; python_version >= \"3.14\""] + +[[package]] +name = "google-auth" +version = "2.49.1" +description = "Google Authentication Library" +optional = false +python-versions = ">=3.8" +groups = ["ci"] +files = [ + {file = "google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7"}, + {file = "google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64"}, +] + +[package.dependencies] +cryptography = ">=38.0.3" +pyasn1-modules = ">=0.2.1" + +[package.extras] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] +cryptography = ["cryptography (>=38.0.3)"] +enterprise-cert = ["pyopenssl"] +pyjwt = ["pyjwt (>=2.0)"] +pyopenssl = ["pyopenssl (>=20.0.0)"] +reauth = ["pyu2f (>=0.1.5)"] +requests = ["requests (>=2.20.0,<3.0.0)"] +rsa = ["rsa (>=3.1.4,<5)"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "flask", "freezegun", "grpcio", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +urllib3 = ["packaging", "urllib3"] + +[[package]] +name = "google-cloud-secret-manager" +version = "2.26.0" +description = "Google Cloud Secret Manager API client library" +optional = false +python-versions = ">=3.7" +groups = ["ci"] +files = [ + {file = "google_cloud_secret_manager-2.26.0-py3-none-any.whl", hash = "sha256:940a5447a6ec9951446fd1a0f22c81a4303fde164cd747aae152c5f5c8e6723e"}, + {file = "google_cloud_secret_manager-2.26.0.tar.gz", hash = "sha256:0d1d6f76327685a0ed78a4cf50f289e1bfbbe56026ed0affa98663b86d6d50d6"}, +] + +[package.dependencies] +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +grpc-google-iam-v1 = ">=0.14.0,<1.0.0" +grpcio = [ + {version = ">=1.75.1,<2.0.0", markers = "python_version >= \"3.14\""}, + {version = ">=1.33.2,<2.0.0", markers = "python_version < \"3.14\""}, +] +proto-plus = {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""} +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + [[package]] name = "googleapis-common-protos" version = "1.73.0" @@ -1148,6 +1270,7 @@ files = [ ] [package.dependencies] +grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [package.extras] @@ -1328,6 +1451,117 @@ files = [ [package.extras] pypi = ["pip (>=24.0)", "platformdirs (>=4.2)", "wheel (>=0.42)"] +[[package]] +name = "grpc-google-iam-v1" +version = "0.14.3" +description = "IAM API client library" +optional = false +python-versions = ">=3.7" +groups = ["ci"] +files = [ + {file = "grpc_google_iam_v1-0.14.3-py3-none-any.whl", hash = "sha256:7a7f697e017a067206a3dfef44e4c634a34d3dee135fe7d7a4613fe3e59217e6"}, + {file = "grpc_google_iam_v1-0.14.3.tar.gz", hash = "sha256:879ac4ef33136c5491a6300e27575a9ec760f6cdf9a2518798c1b8977a5dc389"}, +] + +[package.dependencies] +googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" + +[[package]] +name = "grpcio" +version = "1.78.0" +description = "HTTP/2-based RPC framework" +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"}, + {file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"}, + {file = "grpcio-1.78.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10a9a644b5dd5aec3b82b5b0b90d41c0fa94c85ef42cb42cf78a23291ddb5e7d"}, + {file = "grpcio-1.78.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4c5533d03a6cbd7f56acfc9cfb44ea64f63d29091e40e44010d34178d392d7eb"}, + {file = "grpcio-1.78.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff870aebe9a93a85283837801d35cd5f8814fe2ad01e606861a7fb47c762a2b7"}, + {file = "grpcio-1.78.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:391e93548644e6b2726f1bb84ed60048d4bcc424ce5e4af0843d28ca0b754fec"}, + {file = "grpcio-1.78.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:df2c8f3141f7cbd112a6ebbd760290b5849cda01884554f7c67acc14e7b1758a"}, + {file = "grpcio-1.78.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd8cb8026e5f5b50498a3c4f196f57f9db344dad829ffae16b82e4fdbaea2813"}, + {file = "grpcio-1.78.0-cp310-cp310-win32.whl", hash = "sha256:f8dff3d9777e5d2703a962ee5c286c239bf0ba173877cc68dc02c17d042e29de"}, + {file = "grpcio-1.78.0-cp310-cp310-win_amd64.whl", hash = "sha256:94f95cf5d532d0e717eed4fc1810e8e6eded04621342ec54c89a7c2f14b581bf"}, + {file = "grpcio-1.78.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:2777b783f6c13b92bd7b716667452c329eefd646bfb3f2e9dabea2e05dbd34f6"}, + {file = "grpcio-1.78.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:9dca934f24c732750389ce49d638069c3892ad065df86cb465b3fa3012b70c9e"}, + {file = "grpcio-1.78.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:459ab414b35f4496138d0ecd735fed26f1318af5e52cb1efbc82a09f0d5aa911"}, + {file = "grpcio-1.78.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:082653eecbdf290e6e3e2c276ab2c54b9e7c299e07f4221872380312d8cf395e"}, + {file = "grpcio-1.78.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85f93781028ec63f383f6bc90db785a016319c561cc11151fbb7b34e0d012303"}, + {file = "grpcio-1.78.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f12857d24d98441af6a1d5c87442d624411db486f7ba12550b07788f74b67b04"}, + {file = "grpcio-1.78.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5397fff416b79e4b284959642a4e95ac4b0f1ece82c9993658e0e477d40551ec"}, + {file = "grpcio-1.78.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbe6e89c7ffb48518384068321621b2a69cab509f58e40e4399fdd378fa6d074"}, + {file = "grpcio-1.78.0-cp311-cp311-win32.whl", hash = "sha256:6092beabe1966a3229f599d7088b38dfc8ffa1608b5b5cdda31e591e6500f856"}, + {file = "grpcio-1.78.0-cp311-cp311-win_amd64.whl", hash = "sha256:1afa62af6e23f88629f2b29ec9e52ec7c65a7176c1e0a83292b93c76ca882558"}, + {file = "grpcio-1.78.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:f9ab915a267fc47c7e88c387a3a28325b58c898e23d4995f765728f4e3dedb97"}, + {file = "grpcio-1.78.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3f8904a8165ab21e07e58bf3e30a73f4dffc7a1e0dbc32d51c61b5360d26f43e"}, + {file = "grpcio-1.78.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:859b13906ce098c0b493af92142ad051bf64c7870fa58a123911c88606714996"}, + {file = "grpcio-1.78.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b2342d87af32790f934a79c3112641e7b27d63c261b8b4395350dad43eff1dc7"}, + {file = "grpcio-1.78.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12a771591ae40bc65ba67048fa52ef4f0e6db8279e595fd349f9dfddeef571f9"}, + {file = "grpcio-1.78.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:185dea0d5260cbb2d224c507bf2a5444d5abbb1fa3594c1ed7e4c709d5eb8383"}, + {file = "grpcio-1.78.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:51b13f9aed9d59ee389ad666b8c2214cc87b5de258fa712f9ab05f922e3896c6"}, + {file = "grpcio-1.78.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd5f135b1bd58ab088930b3c613455796dfa0393626a6972663ccdda5b4ac6ce"}, + {file = "grpcio-1.78.0-cp312-cp312-win32.whl", hash = "sha256:94309f498bcc07e5a7d16089ab984d42ad96af1d94b5a4eb966a266d9fcabf68"}, + {file = "grpcio-1.78.0-cp312-cp312-win_amd64.whl", hash = "sha256:9566fe4ababbb2610c39190791e5b829869351d14369603702e890ef3ad2d06e"}, + {file = "grpcio-1.78.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:ce3a90455492bf8bfa38e56fbbe1dbd4f872a3d8eeaf7337dc3b1c8aa28c271b"}, + {file = "grpcio-1.78.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:2bf5e2e163b356978b23652c4818ce4759d40f4712ee9ec5a83c4be6f8c23a3a"}, + {file = "grpcio-1.78.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8f2ac84905d12918e4e55a16da17939eb63e433dc11b677267c35568aa63fc84"}, + {file = "grpcio-1.78.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b58f37edab4a3881bc6c9bca52670610e0c9ca14e2ea3cf9debf185b870457fb"}, + {file = "grpcio-1.78.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:735e38e176a88ce41840c21bb49098ab66177c64c82426e24e0082500cc68af5"}, + {file = "grpcio-1.78.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2045397e63a7a0ee7957c25f7dbb36ddc110e0cfb418403d110c0a7a68a844e9"}, + {file = "grpcio-1.78.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9f136fbafe7ccf4ac7e8e0c28b31066e810be52d6e344ef954a3a70234e1702"}, + {file = "grpcio-1.78.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:748b6138585379c737adc08aeffd21222abbda1a86a0dca2a39682feb9196c20"}, + {file = "grpcio-1.78.0-cp313-cp313-win32.whl", hash = "sha256:271c73e6e5676afe4fc52907686670c7cea22ab2310b76a59b678403ed40d670"}, + {file = "grpcio-1.78.0-cp313-cp313-win_amd64.whl", hash = "sha256:f2d4e43ee362adfc05994ed479334d5a451ab7bc3f3fee1b796b8ca66895acb4"}, + {file = "grpcio-1.78.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:e87cbc002b6f440482b3519e36e1313eb5443e9e9e73d6a52d43bd2004fcfd8e"}, + {file = "grpcio-1.78.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c41bc64626db62e72afec66b0c8a0da76491510015417c127bfc53b2fe6d7f7f"}, + {file = "grpcio-1.78.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8dfffba826efcf366b1e3ccc37e67afe676f290e13a3b48d31a46739f80a8724"}, + {file = "grpcio-1.78.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:74be1268d1439eaaf552c698cdb11cd594f0c49295ae6bb72c34ee31abbe611b"}, + {file = "grpcio-1.78.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be63c88b32e6c0f1429f1398ca5c09bc64b0d80950c8bb7807d7d7fb36fb84c7"}, + {file = "grpcio-1.78.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:3c586ac70e855c721bda8f548d38c3ca66ac791dc49b66a8281a1f99db85e452"}, + {file = "grpcio-1.78.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:35eb275bf1751d2ffbd8f57cdbc46058e857cf3971041521b78b7db94bdaf127"}, + {file = "grpcio-1.78.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:207db540302c884b8848036b80db352a832b99dfdf41db1eb554c2c2c7800f65"}, + {file = "grpcio-1.78.0-cp314-cp314-win32.whl", hash = "sha256:57bab6deef2f4f1ca76cc04565df38dc5713ae6c17de690721bdf30cb1e0545c"}, + {file = "grpcio-1.78.0-cp314-cp314-win_amd64.whl", hash = "sha256:dce09d6116df20a96acfdbf85e4866258c3758180e8c49845d6ba8248b6d0bbb"}, + {file = "grpcio-1.78.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:86f85dd7c947baa707078a236288a289044836d4b640962018ceb9cd1f899af5"}, + {file = "grpcio-1.78.0-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:de8cb00d1483a412a06394b8303feec5dcb3b55f81d83aa216dbb6a0b86a94f5"}, + {file = "grpcio-1.78.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e888474dee2f59ff68130f8a397792d8cb8e17e6b3434339657ba4ee90845a8c"}, + {file = "grpcio-1.78.0-cp39-cp39-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:86ce2371bfd7f212cf60d8517e5e854475c2c43ce14aa910e136ace72c6db6c1"}, + {file = "grpcio-1.78.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0c689c02947d636bc7fab3e30cc3a3445cca99c834dfb77cd4a6cabfc1c5597"}, + {file = "grpcio-1.78.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ce7599575eeb25c0f4dc1be59cada6219f3b56176f799627f44088b21381a28a"}, + {file = "grpcio-1.78.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:684083fd383e9dc04c794adb838d4faea08b291ce81f64ecd08e4577c7398adf"}, + {file = "grpcio-1.78.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ab399ef5e3cd2a721b1038a0f3021001f19c5ab279f145e1146bb0b9f1b2b12c"}, + {file = "grpcio-1.78.0-cp39-cp39-win32.whl", hash = "sha256:f3d6379493e18ad4d39537a82371c5281e153e963cecb13f953ebac155756525"}, + {file = "grpcio-1.78.0-cp39-cp39-win_amd64.whl", hash = "sha256:5361a0630a7fdb58a6a97638ab70e1dae2893c4d08d7aba64ded28bb9e7a29df"}, + {file = "grpcio-1.78.0.tar.gz", hash = "sha256:7382b95189546f375c174f53a5fa873cef91c4b8005faa05cc5b3beea9c4f1c5"}, +] + +[package.dependencies] +typing-extensions = ">=4.12,<5.0" + +[package.extras] +protobuf = ["grpcio-tools (>=1.78.0)"] + +[[package]] +name = "grpcio-status" +version = "1.78.0" +description = "Status proto mapping for gRPC" +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "grpcio_status-1.78.0-py3-none-any.whl", hash = "sha256:b492b693d4bf27b47a6c32590701724f1d3b9444b36491878fb71f6208857f34"}, + {file = "grpcio_status-1.78.0.tar.gz", hash = "sha256:a34cfd28101bfea84b5aa0f936b4b423019e9213882907166af6b3bddc59e189"}, +] + +[package.dependencies] +googleapis-common-protos = ">=1.5.5" +grpcio = ">=1.78.0" +protobuf = ">=6.31.1,<7.0.0" + [[package]] name = "h11" version = "0.16.0" @@ -1543,6 +1777,18 @@ files = [ humanize = ">=3.14.0" jinja2 = "*" +[[package]] +name = "jmespath" +version = "1.1.0" +description = "JSON Matching Expressions" +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"}, + {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"}, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -2814,6 +3060,24 @@ files = [ {file = "propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d"}, ] +[[package]] +name = "proto-plus" +version = "1.27.1" +description = "Beautiful, Pythonic protocol buffers" +optional = false +python-versions = ">=3.7" +groups = ["ci"] +files = [ + {file = "proto_plus-1.27.1-py3-none-any.whl", hash = "sha256:e4643061f3a4d0de092d62aa4ad09fa4756b2cbb89d4627f3985018216f9fefc"}, + {file = "proto_plus-1.27.1.tar.gz", hash = "sha256:912a7460446625b792f6448bade9e55cd4e41e6ac10e27009ef71a7f317fa147"}, +] + +[package.dependencies] +protobuf = ">=3.19.0,<7.0.0" + +[package.extras] +testing = ["google-api-core (>=1.31.5)"] + [[package]] name = "protobuf" version = "6.33.6" @@ -2876,6 +3140,33 @@ valkey = ["valkey-glide (>=2.1.0)"] vault = ["hvac (>=2.3.0)", "types-hvac (>=2.3.0)"] wrappers-encryption = ["cryptography (>=45.0.0)"] +[[package]] +name = "pyasn1" +version = "0.6.3" +description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" +optional = false +python-versions = ">=3.8" +groups = ["ci"] +files = [ + {file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"}, + {file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"}, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +description = "A collection of ASN.1-based protocols modules" +optional = false +python-versions = ">=3.8" +groups = ["ci"] +files = [ + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, +] + +[package.dependencies] +pyasn1 = ">=0.6.1,<0.7.0" + [[package]] name = "pycodestyle" version = "2.14.0" @@ -4033,6 +4324,24 @@ files = [ {file = "ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600"}, ] +[[package]] +name = "s3transfer" +version = "0.16.0" +description = "An Amazon S3 Transfer Manager" +optional = false +python-versions = ">=3.9" +groups = ["ci"] +files = [ + {file = "s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe"}, + {file = "s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920"}, +] + +[package.dependencies] +botocore = ">=1.37.4,<2.0a0" + +[package.extras] +crt = ["botocore[crt] (>=1.37.4,<2.0a0)"] + [[package]] name = "semver" version = "3.0.4" @@ -4857,4 +5166,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.1" python-versions = ">=3.13, <3.15" -content-hash = "7b655afe35515359a21bee45caa6d7f5fb221506565198101b70392eaecb63e4" +content-hash = "1dc0bb464d30417e0477b11e10ba84fca942bb3b59bdb3affa1e9068eefd1153" diff --git a/scripts/dev_env/windows/bootstrap.ps1 b/scripts/dev_env/windows/bootstrap.ps1 new file mode 100644 index 0000000..66efb3e --- /dev/null +++ b/scripts/dev_env/windows/bootstrap.ps1 @@ -0,0 +1,252 @@ +# bootstrap.ps1 — Windows developer environment bootstrapper for Android File Handler +# - Installs Chocolatey (if missing) +# - Installs Vagrant (Chocolatey or direct from HashiCorp) +# - Installs pyenv-win (if missing) +# - Installs Python build dependencies (Chocolatey) +# - Installs zlib from source (https://zlib.net/current/zlib.tar.gz) +# - Installs libffi from GitHub releases (https://github.com/libffi/libffi) +# - Installs latest Python (from pyproject.toml) +# - Sets that Python as global default + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Write-Host "=== Android File Handler Windows Dev Bootstrap ===" -ForegroundColor Cyan + +# --- Install Chocolatey --- +if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Host "Installing Chocolatey..." -ForegroundColor Yellow + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} + +# --- Install Vagrant --- +$vagrantInstalled = Get-Command vagrant -ErrorAction SilentlyContinue +if (-not $vagrantInstalled) { + Write-Host "Installing Vagrant..." -ForegroundColor Yellow + $chocoAvailable = Get-Command choco -ErrorAction SilentlyContinue + if ($chocoAvailable) { + Write-Host " Installing via Chocolatey..." -ForegroundColor Yellow + choco install vagrant -y --no-progress + } + else { + Write-Host " Chocolatey not available — downloading from HashiCorp..." -ForegroundColor Yellow + $vagrantVersion = "2.4.9" + $msiUrl = "https://releases.hashicorp.com/vagrant/$vagrantVersion/vagrant_${vagrantVersion}_windows_amd64.msi" + $msiPath = "$env:TEMP\vagrant_${vagrantVersion}_windows_amd64.msi" + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + Write-Host " Downloading $msiUrl ..." -ForegroundColor Yellow + (New-Object System.Net.WebClient).DownloadFile($msiUrl, $msiPath) + Write-Host " Installing MSI..." -ForegroundColor Yellow + $msiProc = Start-Process -FilePath "msiexec.exe" ` + -ArgumentList "/i", $msiPath, "/qn", "/norestart" ` + -Wait -PassThru + if ($msiProc.ExitCode -ne 0) { + Write-Host "ERROR: Vagrant MSI install failed with exit code $($msiProc.ExitCode)" -ForegroundColor Red + exit 1 + } + Remove-Item $msiPath -Force -ErrorAction SilentlyContinue + } + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + Write-Host " Vagrant installed: $(vagrant --version)" -ForegroundColor Green +} +else { + Write-Host "Vagrant already installed: $(vagrant --version)" -ForegroundColor Green +} + +# --- Install pyenv-win --- +$pyenvRoot = "$env:USERPROFILE\.pyenv" +$pyenvBin = "$pyenvRoot\pyenv-win\bin\pyenv.bat" +if (-not (Test-Path $pyenvBin)) { + Write-Host "Installing pyenv-win..." -ForegroundColor Yellow + choco install pyenv-win -y --no-progress + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} +else { + Write-Host "pyenv-win already installed." -ForegroundColor Green +} + +# --- Install Python build dependencies --- +# Package IDs verified against https://community.chocolatey.org/packages +# zlib has no Chocolatey package; libffi has no Chocolatey package — both omitted +$buildDeps = @('visualcpp-build-tools', 'make', 'openssl.light', 'bzip2', 'SQLite') +$installedPkgs = choco list --no-progress --limit-output 2>&1 | + Where-Object { $_ -match '\|' } | + ForEach-Object { ($_ -split '\|')[0].ToLower() } +$toInstall = @($buildDeps | Where-Object { $_.ToLower() -notin $installedPkgs }) +if ($toInstall.Count -gt 0) { + Write-Host "Installing Python build dependencies: $($toInstall -join ', ')..." -ForegroundColor Yellow + choco install -y --no-progress @toInstall +} else { + Write-Host "Python build dependencies already installed." -ForegroundColor Green +} + + +# Shared install root for libraries not on Chocolatey +$devLibsRoot = "$env:USERPROFILE\.devlibs" +New-Item -ItemType Directory -Path $devLibsRoot -Force | Out-Null + +# --- Install zlib from source (https://zlib.net/current/zlib.tar.gz) --- +$zlibInstallDir = "$devLibsRoot\zlib" +if (-not (Test-Path "$zlibInstallDir\include\zlib.h")) { + Write-Host "Installing zlib from source..." -ForegroundColor Yellow + + # Locate MSVC build environment via vswhere + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + if (-not (Test-Path $vswhere)) { + Write-Host "ERROR: vswhere.exe not found — Visual Studio Build Tools required to build zlib." -ForegroundColor Red + exit 1 + } + $vsPath = (& $vswhere -latest -products * -requires Microsoft.VisualCpp.Tools.HostX86.TargetX64 -property installationPath 2>&1).Trim() + $vcvarsall = "$vsPath\VC\Auxiliary\Build\vcvarsall.bat" + if (-not (Test-Path $vcvarsall)) { + Write-Host "ERROR: vcvarsall.bat not found at $vcvarsall" -ForegroundColor Red + exit 1 + } + + # Download source tarball + $zlibTar = "$env:TEMP\zlib.tar.gz" + Write-Host " Downloading zlib source..." -ForegroundColor Yellow + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + (New-Object System.Net.WebClient).DownloadFile('https://zlib.net/current/zlib.tar.gz', $zlibTar) + + # Extract (tar.exe ships with Windows 10 1803+) + $zlibBuildDir = "$env:TEMP\zlib-build" + if (Test-Path $zlibBuildDir) { Remove-Item $zlibBuildDir -Recurse -Force } + New-Item -ItemType Directory -Path $zlibBuildDir | Out-Null + tar -xzf $zlibTar -C $zlibBuildDir --strip-components=1 + + # Build with MSVC nmake via a wrapper cmd script + Write-Host " Building zlib with MSVC nmake..." -ForegroundColor Yellow + $buildCmd = "$env:TEMP\zlib-build.cmd" + "@echo off`r`ncall `"$vcvarsall`" x64`r`ncd /d `"$zlibBuildDir`"`r`nnmake -f win32\Makefile.msc`r`n" | + Set-Content $buildCmd -Encoding ASCII + $proc = Start-Process cmd.exe -ArgumentList "/c `"$buildCmd`"" -Wait -PassThru -NoNewWindow + Remove-Item $buildCmd -Force -ErrorAction SilentlyContinue + if ($proc.ExitCode -ne 0) { + Write-Host "ERROR: zlib build failed (exit $($proc.ExitCode))" -ForegroundColor Red + exit 1 + } + + # Install headers and libs + New-Item -ItemType Directory -Path "$zlibInstallDir\include" -Force | Out-Null + New-Item -ItemType Directory -Path "$zlibInstallDir\lib" -Force | Out-Null + New-Item -ItemType Directory -Path "$zlibInstallDir\bin" -Force | Out-Null + Copy-Item "$zlibBuildDir\zlib.h" "$zlibInstallDir\include\" + Copy-Item "$zlibBuildDir\zconf.h" "$zlibInstallDir\include\" + Copy-Item "$zlibBuildDir\zlib.lib" "$zlibInstallDir\lib\" + Copy-Item "$zlibBuildDir\zdll.lib" "$zlibInstallDir\lib\" -ErrorAction SilentlyContinue + Copy-Item "$zlibBuildDir\zlib1.dll" "$zlibInstallDir\bin\" -ErrorAction SilentlyContinue + + # Cleanup + Remove-Item $zlibTar -Force -ErrorAction SilentlyContinue + Remove-Item $zlibBuildDir -Recurse -Force -ErrorAction SilentlyContinue + + Write-Host "zlib installed to $zlibInstallDir" -ForegroundColor Green +} else { + Write-Host "zlib already installed at $zlibInstallDir" -ForegroundColor Green +} + +# Add zlib bin dir to user PATH (persists across sessions; also updates current session) +$zlibBinDir = "$zlibInstallDir\bin" +$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User') +if ($userPath -notmatch [regex]::Escape($zlibBinDir)) { + [System.Environment]::SetEnvironmentVariable('Path', "$userPath;$zlibBinDir", 'User') + Write-Host " Added $zlibBinDir to user PATH" -ForegroundColor Green +} +$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + + [System.Environment]::GetEnvironmentVariable('Path', 'User') + +# --- Install libffi from GitHub releases (https://github.com/libffi/libffi) --- +$libffiInstallDir = "$devLibsRoot\libffi" +if (-not (Test-Path "$libffiInstallDir\include\ffi.h")) { + Write-Host "Fetching latest libffi release from GitHub..." -ForegroundColor Yellow + + $apiResponse = Invoke-RestMethod ` + -Uri 'https://api.github.com/repos/libffi/libffi/releases/latest' ` + -Headers @{ 'User-Agent' = 'android-file-handler-bootstrap' } + $libffiTag = $apiResponse.tag_name # e.g. "v3.5.2" + $libffiVersion = $libffiTag -replace '^v', '' # e.g. "3.5.2" + Write-Host " Latest libffi: $libffiTag" -ForegroundColor Yellow + + $zipName = "libffi-$libffiVersion-x86-64bit-msvc-binaries.zip" + $downloadUrl = "https://github.com/libffi/libffi/releases/download/$libffiTag/$zipName" + $zipPath = "$env:TEMP\$zipName" + + Write-Host " Downloading $zipName..." -ForegroundColor Yellow + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + (New-Object System.Net.WebClient).DownloadFile($downloadUrl, $zipPath) + + # Extract — zip contains a single top-level subdirectory + $extractTemp = "$env:TEMP\libffi-extract" + if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force } + Expand-Archive -Path $zipPath -DestinationPath $extractTemp -Force + + if (Test-Path $libffiInstallDir) { Remove-Item $libffiInstallDir -Recurse -Force } + $innerDir = Get-ChildItem $extractTemp -Directory | Select-Object -First 1 + if ($innerDir) { + Move-Item $innerDir.FullName $libffiInstallDir + } else { + # Flat zip — use the extract root directly + Move-Item $extractTemp $libffiInstallDir + } + + Remove-Item $zipPath -Force -ErrorAction SilentlyContinue + if (Test-Path $extractTemp) { Remove-Item $extractTemp -Recurse -Force -ErrorAction SilentlyContinue } + + Write-Host "libffi $libffiVersion installed to $libffiInstallDir" -ForegroundColor Green +} else { + Write-Host "libffi already installed at $libffiInstallDir" -ForegroundColor Green +} + +# --- Parse Python version from pyproject.toml --- +$scriptDir = if ($PSScriptRoot) { $PSScriptRoot } else { Split-Path -Parent $MyInvocation.MyCommand.Path } +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..\..") +$pyprojectPath = Join-Path $repoRoot "pyproject.toml" +if (-not (Test-Path $pyprojectPath)) { + Write-Host "ERROR: pyproject.toml not found at $pyprojectPath" -ForegroundColor Red + exit 1 +} +$pyproject = Get-Content $pyprojectPath -Raw +$pyverMatch = $pyproject | Select-String -Pattern 'requires-python\s*=\s*"([^"]+)"' | ForEach-Object { $_.Matches[0].Groups[1].Value } +if (-not $pyverMatch) { + Write-Host "ERROR: Could not find requires-python in pyproject.toml" -ForegroundColor Red + exit 1 +} +# Use the highest version in the range (e.g. ">=3.13, <3.15" → 3.14.x) +$pyver = $pyverMatch -replace '[^0-9.,<>= ]', '' +$maxVer = ($pyver -split ',') | Where-Object { $_ -match '<' } | ForEach-Object { $_ -replace '[^0-9.]', '' } | Sort-Object -Descending | Select-Object -First 1 +if ($maxVer) { + $major, $minor = $maxVer -split '\.' + $targetMinor = [int]$minor - 1 + $targetVer = "$major.$targetMinor.x" +} else { + $targetVer = $pyver -replace '[^0-9.]', '' +} +Write-Host "Target Python version: $targetVer" -ForegroundColor Cyan + +# --- Install and set global Python version --- +$pyenv = "pyenv" +if (-not (Get-Command $pyenv -ErrorAction SilentlyContinue)) { + $pyenv = "$pyenvBin" +} + +$versionPrefix = $targetVer -replace '\.x$', '' +$installedVersions = (& $pyenv versions 2>&1) | Out-String +if ($installedVersions -notmatch [regex]::Escape($versionPrefix)) { + Write-Host "Installing Python $targetVer via pyenv-win..." -ForegroundColor Yellow + & $pyenv install $targetVer -q +} else { + Write-Host "Python $targetVer already installed via pyenv." -ForegroundColor Green +} + +$currentGlobal = ((& $pyenv global 2>&1) | Out-String).Trim() +if ($currentGlobal -notmatch [regex]::Escape($versionPrefix)) { + & $pyenv global $targetVer + Write-Host "pyenv-win global version set to $targetVer" -ForegroundColor Green +} else { + Write-Host "pyenv-win global already set to $currentGlobal" -ForegroundColor Green +} + +Write-Host "=== Bootstrap complete ===" -ForegroundColor Cyan diff --git a/scripts/spec_scripts/android-file-handler-arch.spec b/scripts/spec_scripts/android-file-handler-arch.spec index 3c75cb4..bc6a738 100644 --- a/scripts/spec_scripts/android-file-handler-arch.spec +++ b/scripts/spec_scripts/android-file-handler-arch.spec @@ -1,10 +1,13 @@ +import os block_cipher = None +SPEC_DIR = os.path.dirname(os.path.abspath(SPEC)) +SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src')) a = Analysis( - ['../../src/main.py'], - pathex=['src'], + [os.path.join(SRC_DIR, 'main.py')], + pathex=[SRC_DIR], binaries=[], datas=[ - ('../../src/gui', 'gui'), + (os.path.join(SRC_DIR, 'gui'), 'gui'), ], hiddenimports=[ # GUI modules @@ -33,7 +36,8 @@ a = Analysis( 'managers.transfer_manager', # Utility modules 'utils', - 'utils.file_deduplication' + 'utils.file_deduplication', + 'utils.security_utils' ], hookspath=[], hooksconfig={}, diff --git a/scripts/spec_scripts/android-file-handler-debian.spec b/scripts/spec_scripts/android-file-handler-debian.spec index 69bc97a..99f6255 100644 --- a/scripts/spec_scripts/android-file-handler-debian.spec +++ b/scripts/spec_scripts/android-file-handler-debian.spec @@ -1,12 +1,15 @@ # -*- mode: python ; coding: utf-8 -*- +import os block_cipher = None +SPEC_DIR = os.path.dirname(os.path.abspath(SPEC)) +SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src')) a = Analysis( - ['../../src/main.py'], - pathex=['src'], + [os.path.join(SRC_DIR, 'main.py')], + pathex=[SRC_DIR], binaries=[], datas=[ - ('../../src/gui', 'gui'), - ('../../scripts/debian_postinst.sh', 'scripts'), + (os.path.join(SRC_DIR, 'gui'), 'gui'), + (os.path.join(SPEC_DIR, '..', 'debian_postinst.sh'), 'scripts'), ], hiddenimports=[ # GUI modules @@ -35,7 +38,8 @@ a = Analysis( 'managers.transfer_manager', # Utility modules 'utils', - 'utils.file_deduplication' + 'utils.file_deduplication', + 'utils.security_utils' ], hookspath=[], hooksconfig={}, diff --git a/scripts/spec_scripts/android-file-handler-rhel.spec b/scripts/spec_scripts/android-file-handler-rhel.spec index 85ffb8b..276ba3f 100644 --- a/scripts/spec_scripts/android-file-handler-rhel.spec +++ b/scripts/spec_scripts/android-file-handler-rhel.spec @@ -1,11 +1,14 @@ +import os block_cipher = None +SPEC_DIR = os.path.dirname(os.path.abspath(SPEC)) +SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src')) a = Analysis( - ['../../src/main.py'], - pathex=['src'], + [os.path.join(SRC_DIR, 'main.py')], + pathex=[SRC_DIR], binaries=[], datas=[ - ('../../src/gui', 'gui'), - ('../../scripts/rhel_postinst.sh', 'scripts'), + (os.path.join(SRC_DIR, 'gui'), 'gui'), + (os.path.join(SPEC_DIR, '..', 'rhel_postinst.sh'), 'scripts'), ], hiddenimports=[ # GUI modules @@ -34,7 +37,8 @@ a = Analysis( 'managers.transfer_manager', # Utility modules 'utils', - 'utils.file_deduplication' + 'utils.file_deduplication', + 'utils.security_utils' ], hookspath=[], hooksconfig={}, diff --git a/scripts/spec_scripts/android-file-handler-windows.spec b/scripts/spec_scripts/android-file-handler-windows.spec index 41c7197..1ce6442 100644 --- a/scripts/spec_scripts/android-file-handler-windows.spec +++ b/scripts/spec_scripts/android-file-handler-windows.spec @@ -1,11 +1,14 @@ # -*- mode: python ; coding: utf-8 -*- +import os block_cipher = None +SPEC_DIR = os.path.dirname(os.path.abspath(SPEC)) +SRC_DIR = os.path.normpath(os.path.join(SPEC_DIR, '..', '..', 'src')) a = Analysis( - ['../../src/main.py'], - pathex=['../..'], + [os.path.join(SRC_DIR, 'main.py')], + pathex=[SRC_DIR], binaries=[], datas=[ - ('../../src/gui', 'gui'), + (os.path.join(SRC_DIR, 'gui'), 'gui'), ], hiddenimports=[ # GUI modules @@ -34,7 +37,8 @@ a = Analysis( 'managers.transfer_manager', # Utility modules 'utils', - 'utils.file_deduplication' + 'utils.file_deduplication', + 'utils.security_utils' ], hookspath=[], hooksconfig={}, @@ -66,5 +70,5 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, - icon='../../icon_media/robot_files_256.ico', + icon=os.path.join(SPEC_DIR, '..', '..', 'icon_media', 'robot_files_256.ico'), ) \ No newline at end of file diff --git a/scripts/windows/run_vagrant_tests.ps1 b/scripts/windows/run_vagrant_tests.ps1 new file mode 100644 index 0000000..fc38c40 --- /dev/null +++ b/scripts/windows/run_vagrant_tests.ps1 @@ -0,0 +1,298 @@ +# run_vagrant_tests.ps1 — Host-side orchestrator for Vagrant Windows 11 test cycle. +# +# Workflow: +# 1. Build the installer locally (PyInstaller + Inno Setup) +# 2. Ensure the Vagrant VM is running and has a "clean" snapshot +# 3. Restore to the clean snapshot +# 4. Push the installer to the VM +# 5. Run the test script inside the VM via WinRM +# 6. Retrieve screenshots and results to the host +# 7. Print results summary to CLI +# 8. Revert VM back to clean snapshot +# +# Usage (from repo root): +# .\scripts\windows\run_vagrant_tests.ps1 +# .\scripts\windows\run_vagrant_tests.ps1 -SkipBuild +# .\scripts\windows\run_vagrant_tests.ps1 -SkipRevert +# +# Prerequisites: +# - Vagrant and Hyper-V enabled on the host +# - Inno Setup 6 installed (default path or in PATH) +# - Poetry environment set up +# - The VM created and "clean" snapshot saved: +# cd vagrant && vagrant up --provider=hyperv && vagrant snapshot save clean + +[CmdletBinding()] +param( + [switch]$SkipBuild, + [switch]$SkipRevert, + [string]$SnapshotName = "clean", + [string]$LocalResultsDir = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$VagrantDir = Join-Path $RepoRoot "vagrant" +$Timestamp = Get-Date -Format "yyyyMMdd_HHmmss" + +if (-not $LocalResultsDir) { + $LocalResultsDir = Join-Path $RepoRoot "vagrant_test_results\$Timestamp" +} + +function Write-Step { + param([string]$Message) + Write-Host "`n>>> $Message" -ForegroundColor Cyan +} + +# ── Ensure we're in the vagrant directory ──────────────────────────────────── + +Push-Location $VagrantDir +try { + + # ── Step 1: Build the installer locally ────────────────────────────────── + + $installerPath = Join-Path $RepoRoot "dist\android-file-handler-setup.exe" + + if ($SkipBuild) { + Write-Step "Skipping build (-SkipBuild specified)" + if (-not (Test-Path $installerPath)) { + Write-Host "ERROR: -SkipBuild was specified but installer not found at $installerPath" -ForegroundColor Red + exit 1 + } + } + else { + Write-Step "Building installer locally (PyInstaller + Inno Setup)" + + # PyInstaller — compile the exe + Push-Location $RepoRoot + try { + Write-Host " Running PyInstaller..." -ForegroundColor Yellow + poetry run pyinstaller scripts\spec_scripts\android-file-handler-windows.spec --noconfirm + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: PyInstaller failed with exit code $LASTEXITCODE" -ForegroundColor Red + exit 1 + } + + $pyinstallerExe = Join-Path $RepoRoot "dist\android-file-handler-windows.exe" + if (-not (Test-Path $pyinstallerExe)) { + Write-Host "ERROR: PyInstaller output not found at $pyinstallerExe" -ForegroundColor Red + exit 1 + } + Write-Host " PyInstaller output: $pyinstallerExe" -ForegroundColor Green + + # Inno Setup — compile the installer + Write-Host " Running Inno Setup..." -ForegroundColor Yellow + $isccPaths = @( + "C:\Program Files (x86)\Inno Setup 6\ISCC.exe", + "C:\Program Files\Inno Setup 6\ISCC.exe" + ) + $iscc = $isccPaths | Where-Object { Test-Path $_ } | Select-Object -First 1 + if (-not $iscc) { + $iscc = Get-Command ISCC.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source + } + if (-not $iscc) { + Write-Host "ERROR: Inno Setup (ISCC.exe) not found. Install Inno Setup 6 or add it to PATH." -ForegroundColor Red + exit 1 + } + + & $iscc "scripts\windows\android-file-handler-setup.iss" + if ($LASTEXITCODE -ne 0) { + Write-Host "ERROR: Inno Setup failed with exit code $LASTEXITCODE" -ForegroundColor Red + exit 1 + } + + if (-not (Test-Path $installerPath)) { + Write-Host "ERROR: Installer not found at $installerPath after Inno Setup" -ForegroundColor Red + exit 1 + } + Write-Host " Installer built: $installerPath" -ForegroundColor Green + } + finally { + Pop-Location + } + } + + # ── Step 2: Verify VM and snapshot exist ───────────────────────────────── + + Write-Step "Checking Vagrant VM status" + $status = vagrant status --machine-readable 2>&1 | + Select-String "state,running" -Quiet + + if (-not $status) { + Write-Host "VM is not running. Starting it..." -ForegroundColor Yellow + vagrant up --provider=hyperv + } + + Write-Step "Verifying '$SnapshotName' snapshot exists" + $snapshots = vagrant snapshot list 2>&1 + if ($snapshots -notmatch [regex]::Escape($SnapshotName)) { + Write-Host "Snapshot '$SnapshotName' not found. Creating it now..." -ForegroundColor Yellow + vagrant snapshot save $SnapshotName + } + + # ── Step 3: Restore to clean snapshot ──────────────────────────────────── + + Write-Step "Restoring VM to '$SnapshotName' snapshot" + vagrant snapshot restore $SnapshotName + + # ── Step 4: Push installer to the VM ───────────────────────────────────── + + Write-Step "Pushing installer to the VM" + + $remoteInstallerDir = "C:\vagrant_installer" + $remoteInstallerPath = "$remoteInstallerDir\android-file-handler-setup.exe" + + vagrant winrm --shell powershell --command ` + "New-Item -ItemType Directory -Path '$remoteInstallerDir' -Force | Out-Null" + + # Base64-encode the installer, transfer via WinRM, decode on the VM + $installerBytes = [IO.File]::ReadAllBytes($installerPath) + $installerSizeMB = [math]::Round($installerBytes.Length / 1MB, 1) + Write-Host " Installer size: ${installerSizeMB} MB — transferring via WinRM..." -ForegroundColor Yellow + + # Split into chunks to avoid WinRM message size limits (< 2MB per command) + $chunkSize = 1 * 1024 * 1024 # 1 MB chunks + $totalChunks = [math]::Ceiling($installerBytes.Length / $chunkSize) + + vagrant winrm --shell powershell --command ` + "if (Test-Path '$remoteInstallerPath') { Remove-Item '$remoteInstallerPath' -Force }" + + for ($idx = 0; $idx -lt $totalChunks; $idx++) { + $offset = $idx * $chunkSize + $length = [math]::Min($chunkSize, $installerBytes.Length - $offset) + $chunk = New-Object byte[] $length + [Array]::Copy($installerBytes, $offset, $chunk, 0, $length) + $b64Chunk = [Convert]::ToBase64String($chunk) + + $appendScript = if ($idx -eq 0) { + "[IO.File]::WriteAllBytes('$remoteInstallerPath', [Convert]::FromBase64String('$b64Chunk'))" + } + else { + "`$bytes = [Convert]::FromBase64String('$b64Chunk'); " + + "`$stream = [IO.File]::Open('$remoteInstallerPath', 'Append'); " + + "`$stream.Write(`$bytes, 0, `$bytes.Length); `$stream.Close()" + } + vagrant winrm --shell powershell --command $appendScript + Write-Host " Chunk $($idx + 1)/$totalChunks transferred" -ForegroundColor DarkGray + } + + # Verify transfer + $remoteSize = vagrant winrm --shell powershell --command ` + "(Get-Item '$remoteInstallerPath').Length" + Write-Host " Remote file size: $($remoteSize.Trim()) bytes (expected: $($installerBytes.Length))" -ForegroundColor DarkGray + + # ── Step 5: Run tests inside the VM ────────────────────────────────────── + + Write-Step "Running test suite inside the VM" + + $testExitCode = 0 + try { + vagrant winrm --shell powershell --command ` + "Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -Force; & C:\vagrant_project\vagrant\test_installer.ps1 -InstallerPath 'C:\vagrant_installer\android-file-handler-setup.exe'" + } + catch { + $testExitCode = 1 + Write-Host "Test script returned failures." -ForegroundColor Yellow + } + + # ── Step 6: Retrieve results and screenshots ───────────────────────────── + + Write-Step "Retrieving test results and screenshots" + + if (-not (Test-Path $LocalResultsDir)) { + New-Item -ItemType Directory -Path $LocalResultsDir -Force | Out-Null + } + + # Copy results from shared folder on VM via vagrant scp or direct file copy + # The test writes to C:\vagrant_test_results inside the VM. + # We retrieve via WinRM by reading files. + try { + # Get list of result files + $remoteFiles = vagrant winrm --shell powershell --command ` + "if (Test-Path 'C:\vagrant_test_results') { Get-ChildItem 'C:\vagrant_test_results' -Name }" 2>&1 | + Where-Object { $_ -is [string] -and $_.Trim() -ne "" } + + foreach ($fileName in $remoteFiles) { + $trimmedName = $fileName.Trim() + if (-not $trimmedName) { continue } + + $remotePath = "C:\vagrant_test_results\$trimmedName" + $localPath = Join-Path $LocalResultsDir $trimmedName + + if ($trimmedName -match '\.csv$') { + # Text file — read content and save + $content = vagrant winrm --shell powershell --command ` + "Get-Content -Path '$remotePath' -Raw" + $content | Out-File -FilePath $localPath -Encoding utf8 + Write-Host " Retrieved: $trimmedName" -ForegroundColor DarkGray + } + elseif ($trimmedName -match '\.(png|jpg|bmp)$') { + # Binary file — base64 encode, transfer, decode + $b64 = vagrant winrm --shell powershell --command ` + "[Convert]::ToBase64String([IO.File]::ReadAllBytes('$remotePath'))" + [IO.File]::WriteAllBytes($localPath, [Convert]::FromBase64String($b64.Trim())) + Write-Host " Retrieved: $trimmedName" -ForegroundColor DarkGray + } + } + } + catch { + Write-Host " Warning: Could not retrieve some result files: $_" -ForegroundColor Yellow + } + + # ── Step 7: Print local summary ────────────────────────────────────────── + + Write-Step "Test Results" + + $csvPath = Join-Path $LocalResultsDir "test_results.csv" + if (Test-Path $csvPath) { + $results = Import-Csv $csvPath + $results | Format-Table -AutoSize + + $passCount = ($results | Where-Object { $_.Status -eq "PASS" }).Count + $failCount = ($results | Where-Object { $_.Status -eq "FAIL" }).Count + Write-Host " Passed: $passCount | Failed: $failCount | Total: $($passCount + $failCount)" -ForegroundColor $(if ($failCount -gt 0) { "Red" } else { "Green" }) + } + else { + Write-Host " No results CSV found. Check VM output above." -ForegroundColor Yellow + } + + Write-Host "`n Results directory: $LocalResultsDir" -ForegroundColor White + + # List screenshots + $screenshots = Get-ChildItem $LocalResultsDir -Filter "*.png" -ErrorAction SilentlyContinue + if ($screenshots) { + Write-Host " Screenshots:" -ForegroundColor White + foreach ($shot in $screenshots) { + Write-Host " - $($shot.FullName)" -ForegroundColor DarkGray + } + } + + # ── Step 8: Revert to clean snapshot ───────────────────────────────────── + + if (-not $SkipRevert) { + Write-Step "Reverting VM to '$SnapshotName' snapshot" + vagrant snapshot restore $SnapshotName + Write-Host "VM reverted to clean state." -ForegroundColor Green + } + else { + Write-Host "`n -SkipRevert specified, VM left in current state." -ForegroundColor Yellow + } + + # ── Exit ───────────────────────────────────────────────────────────────── + + Write-Host "" + if ($testExitCode -ne 0) { + Write-Host "One or more tests FAILED." -ForegroundColor Red + exit 1 + } + else { + Write-Host "All tests PASSED." -ForegroundColor Green + exit 0 + } + +} +finally { + Pop-Location +} diff --git a/vagrant/README.md b/vagrant/README.md new file mode 100644 index 0000000..50f73c2 --- /dev/null +++ b/vagrant/README.md @@ -0,0 +1,89 @@ +# Vagrant Windows 11 Test Environment + +Automated testing of the Android File Handler installer on a clean Windows 11 VM using Hyper-V. + +## Prerequisites + +- [Vagrant](https://www.vagrantup.com/) >= 2.4 +- Hyper-V enabled (`Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All`) +- [Inno Setup 6](https://jrsoftware.org/isdl.php) installed (default path or ISCC.exe in PATH) +- Poetry environment set up (`poetry install`) + +## Setup (one-time) + +```powershell +cd vagrant +vagrant up --provider=hyperv +vagrant snapshot save clean +``` + +> **Note:** Vagrant will prompt for your Windows credentials to set up the SMB synced folder. +> You can set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to skip the prompt. + +## Running Tests + +From the repo root: + +```powershell +.\scripts\windows\run_vagrant_tests.ps1 +``` + +This will: + +1. Build the installer locally (PyInstaller + Inno Setup) +2. Restore the VM to the `clean` snapshot +3. Push the installer to the VM via WinRM +4. Run the installer silently inside the VM +5. Verify installation (directory, exe, shortcuts, registry) +6. Launch the app, find the window, take a GUI screenshot +7. Run silent uninstall and verify cleanup +8. Retrieve results CSV + screenshots to `vagrant_test_results\\` +9. Print a summary table to the CLI +10. Revert the VM back to the `clean` snapshot + +### Options + +| Flag | Description | +|---|---| +| `-SkipBuild` | Skip the local build step (use an existing installer in `dist\`) | +| `-SkipRevert` | Leave the VM in post-test state (useful for debugging) | +| `-SnapshotName ` | Use a different snapshot name (default: `clean`) | +| `-LocalResultsDir ` | Override where results are saved | + +## What Gets Tested + +| # | Test | Description | +|---|---|---| +| 1 | Installer exists | Checks the built `.exe` is present | +| 2 | Silent install | Runs the Inno Setup installer with `/VERYSILENT` | +| 3 | Install directory | Verifies `C:\Program Files\Android File Handler` exists | +| 4 | Executable present | Checks the app `.exe` is in the install directory | +| 5 | Start Menu shortcut | Verifies the Start Menu entry was created | +| 6 | Registry entry | Checks Add/Remove Programs registration | +| 7 | App launches | Starts the app and confirms it stays running | +| 8 | Window found | Finds the main window by its title | +| 9 | Silent uninstall | Runs the uninstaller and verifies cleanup | + +## File Structure + +``` +vagrant/ +├── Vagrantfile # VM definition (Windows 11 Enterprise, Hyper-V) +├── provision.ps1 # VM provisioning (nircmd for screenshots) +├── test_installer.ps1 # Test script that runs inside the VM +└── README.md # This file +scripts/windows/ +└── run_vagrant_tests.ps1 # Host-side orchestrator (builds, pushes, tests, reverts) +``` + +## Troubleshooting + +**VM won't start**: Ensure Hyper-V is enabled and you are running the terminal as Administrator. + +**WinRM connection fails**: The box uses plaintext WinRM on port 55985. Ensure no firewall blocks it. + +**SMB share prompt**: Set `VAGRANT_SMB_USERNAME` and `VAGRANT_SMB_PASSWORD` environment variables to avoid interactive credential prompts. + +**Inno Setup not found**: Install Inno Setup 6 to the default path or add `ISCC.exe` to your PATH. + +**Want to inspect the VM manually**: Use `vagrant rdp` or pass `-SkipRevert`. diff --git a/vagrant/Vagrantfile b/vagrant/Vagrantfile new file mode 100644 index 0000000..f28f416 --- /dev/null +++ b/vagrant/Vagrantfile @@ -0,0 +1,54 @@ +# -*- mode: ruby -*- +# vi: set ft=ruby : +# +# Vagrant Windows 11 test machine for Android File Handler installer testing. +# +# Prerequisites: +# - Vagrant >= 2.4 +# - Hyper-V enabled (Windows host) +# +# Usage: +# cd vagrant +# vagrant up --provider=hyperv +# vagrant snapshot save clean +# ..\scripts\windows\run_vagrant_tests.ps1 +# + +Vagrant.configure("2") do |config| + config.vm.box = "gusztavvargadr/windows-11-24h2-enterprise" + + config.vm.hostname = "afh-test" + config.vm.communicator = "winrm" + + config.winrm.username = "vagrant" + config.winrm.password = "vagrant" + config.winrm.transport = :plaintext + config.winrm.basic_auth_only = true + + config.vm.guest = :windows + config.vm.boot_timeout = 900 + config.vm.graceful_halt_timeout = 120 + + config.vm.network "forwarded_port", guest: 5985, host: 55985 + + config.vm.provider "hyperv" do |hv| + hv.vmname = "afh-win11-test" + hv.memory = 4096 + hv.maxmemory = 4096 + hv.cpus = 2 + hv.enable_enhanced_session_mode = true + hv.linked_clone = true + end + + # SMB synced folder — Hyper-V does not support VirtualBox shared folders. + # Vagrant will prompt for host credentials on first `vagrant up` to set up the SMB share. + config.vm.synced_folder "..", "/vagrant_project", + type: "smb", + smb_username: ENV["VAGRANT_SMB_USERNAME"], + smb_password: ENV["VAGRANT_SMB_PASSWORD"], + mount_options: ["ro"] + + config.vm.provision "shell", + path: "provision.ps1", + privileged: true +end diff --git a/vagrant/provision.ps1 b/vagrant/provision.ps1 new file mode 100644 index 0000000..645ee9f --- /dev/null +++ b/vagrant/provision.ps1 @@ -0,0 +1,30 @@ +# provision.ps1 — Vagrant provisioning script for Windows 11 test VM. +# Installs prerequisites needed to test the Android File Handler installer. + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +Write-Host "=== Provisioning Android File Handler test VM ===" -ForegroundColor Cyan + +# --- Enable auto-logon so GUI tests work after reboot --- +$RegPath = "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" +Set-ItemProperty -Path $RegPath -Name AutoAdminLogon -Value "1" +Set-ItemProperty -Path $RegPath -Name DefaultUserName -Value "vagrant" +Set-ItemProperty -Path $RegPath -Name DefaultPassword -Value "vagrant" + +# --- Install Chocolatey (package manager) --- +if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { + Write-Host "Installing Chocolatey..." -ForegroundColor Yellow + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") +} + +# --- Install nircmd for screenshot capture --- +Write-Host "Installing nircmd for screenshot capture..." -ForegroundColor Yellow +choco install nircmd -y --no-progress + +# Refresh PATH +$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + +Write-Host "=== Provisioning complete ===" -ForegroundColor Green diff --git a/vagrant/test_installer.ps1 b/vagrant/test_installer.ps1 new file mode 100644 index 0000000..1a1603b --- /dev/null +++ b/vagrant/test_installer.ps1 @@ -0,0 +1,201 @@ +# test_installer.ps1 — Runs INSIDE the Vagrant VM via WinRM. +# Tests installation and basic function of Android File Handler. +# +# Exit codes: 0 = all passed, 1 = one or more failures. + +param( + [string]$InstallerPath = "C:\vagrant_project\dist\android-file-handler-setup.exe", + [string]$ScreenshotDir = "C:\vagrant_test_results" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +# ── Helpers ────────────────────────────────────────────────────────────────── + +$Script:Passed = 0 +$Script:Failed = 0 +$Script:Results = @() + +function Write-TestResult { + param([string]$Name, [bool]$Pass, [string]$Detail = "") + $status = if ($Pass) { "PASS" } else { "FAIL" } + $color = if ($Pass) { "Green" } else { "Red" } + $msg = "[$status] $Name" + if ($Detail) { $msg += " — $Detail" } + Write-Host $msg -ForegroundColor $color + if ($Pass) { $Script:Passed++ } else { $Script:Failed++ } + $Script:Results += [PSCustomObject]@{ Test = $Name; Status = $status; Detail = $Detail } +} + +function Take-Screenshot { + param([string]$Name) + if (-not (Test-Path $ScreenshotDir)) { + New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null + } + $outFile = Join-Path $ScreenshotDir "$Name.png" + $nircmd = Get-Command nircmd -ErrorAction SilentlyContinue + if ($nircmd) { + Start-Process -FilePath $nircmd.Source -ArgumentList "savescreenshot", $outFile -NoNewWindow -Wait + return $outFile + } + # Fallback: .NET screenshot + Add-Type -AssemblyName System.Windows.Forms + Add-Type -AssemblyName System.Drawing + $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bitmap = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + $graphics.CopyFromScreen($screen.Location, [System.Drawing.Point]::Empty, $screen.Size) + $bitmap.Save($outFile, [System.Drawing.Imaging.ImageFormat]::Png) + $graphics.Dispose() + $bitmap.Dispose() + return $outFile +} + +# ── Setup ──────────────────────────────────────────────────────────────────── + +$AppName = "Android File Handler" +$ExeName = "android-file-handler-windows.exe" +$DefaultInstDir = Join-Path $env:ProgramFiles $AppName + +Write-Host "" +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host " Android File Handler — Vagrant Test Suite " -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host "" + +if (-not (Test-Path $ScreenshotDir)) { + New-Item -ItemType Directory -Path $ScreenshotDir -Force | Out-Null +} + +# ── Test 1: Installer exists ──────────────────────────────────────────────── + +$installerExists = Test-Path $InstallerPath +Write-TestResult -Name "Installer file exists" -Pass $installerExists -Detail $InstallerPath + +if (-not $installerExists) { + Write-Host "`nFATAL: Installer not found at $InstallerPath. Build the installer first." -ForegroundColor Red + Write-Host " poetry run pyinstaller scripts\spec_scripts\android-file-handler-windows.spec" + Write-Host " iscc scripts\windows\android-file-handler-setup.iss" + exit 1 +} + +# ── Test 2: Silent install ────────────────────────────────────────────────── + +Write-Host "`n--- Running silent install ---" -ForegroundColor Yellow +$installProc = Start-Process -FilePath $InstallerPath ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-" ` + -Wait -PassThru + +$installOk = $installProc.ExitCode -eq 0 +Write-TestResult -Name "Silent install completed" -Pass $installOk -Detail "Exit code: $($installProc.ExitCode)" + +# ── Test 3: Install directory created ──────────────────────────────────────── + +$dirExists = Test-Path $DefaultInstDir +Write-TestResult -Name "Install directory exists" -Pass $dirExists -Detail $DefaultInstDir + +# ── Test 4: Executable present ────────────────────────────────────────────── + +$exePath = Join-Path $DefaultInstDir $ExeName +$exeExists = Test-Path $exePath +Write-TestResult -Name "Executable exists" -Pass $exeExists -Detail $exePath + +# ── Test 5: Start Menu shortcut ───────────────────────────────────────────── + +$startMenuDir = Join-Path $env:ProgramData "Microsoft\Windows\Start Menu\Programs\$AppName" +$shortcutPath = Join-Path $startMenuDir "$AppName.lnk" +$shortcutExists = Test-Path $shortcutPath +Write-TestResult -Name "Start Menu shortcut exists" -Pass $shortcutExists -Detail $shortcutPath + +# ── Test 6: Registry uninstall entry ──────────────────────────────────────── + +$uninstallKey = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall" +$registryEntry = Get-ChildItem $uninstallKey -ErrorAction SilentlyContinue | + Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).DisplayName -eq $AppName } +$registryOk = $null -ne $registryEntry +Write-TestResult -Name "Registry uninstall entry" -Pass $registryOk + +# ── Test 7: Launch application & capture GUI ───────────────────────────────── + +if ($exeExists) { + Write-Host "`n--- Launching application ---" -ForegroundColor Yellow + + $appProc = Start-Process -FilePath $exePath -PassThru + Start-Sleep -Seconds 8 + + # Check if process is still running (GUI app should stay alive) + $appRunning = -not $appProc.HasExited + Write-TestResult -Name "Application launches and stays running" -Pass $appRunning + + # Look for the window by title + Add-Type @" + using System; + using System.Runtime.InteropServices; + public class Win32 { + [DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Auto)] + public static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + } +"@ + $hwnd = [Win32]::FindWindow($null, $AppName) + $windowFound = $hwnd -ne [IntPtr]::Zero + Write-TestResult -Name "Main window found by title" -Pass $windowFound -Detail "HWND: $hwnd" + + # Take screenshot of the running application + Start-Sleep -Seconds 2 + $screenshotFile = Take-Screenshot -Name "app_running" + Write-Host " Screenshot saved: $screenshotFile" -ForegroundColor DarkGray + + # Gracefully close + if (-not $appProc.HasExited) { + $appProc.CloseMainWindow() | Out-Null + Start-Sleep -Seconds 3 + if (-not $appProc.HasExited) { + $appProc.Kill() + } + } +} +else { + Write-TestResult -Name "Application launches and stays running" -Pass $false -Detail "Skipped — exe not found" + Write-TestResult -Name "Main window found by title" -Pass $false -Detail "Skipped — exe not found" +} + +# ── Test 8: Silent uninstall ──────────────────────────────────────────────── + +Write-Host "`n--- Running silent uninstall ---" -ForegroundColor Yellow +$uninstallerPath = Join-Path $DefaultInstDir "unins000.exe" +if (Test-Path $uninstallerPath) { + $uninstProc = Start-Process -FilePath $uninstallerPath ` + -ArgumentList "/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART" ` + -Wait -PassThru + $uninstallOk = $uninstProc.ExitCode -eq 0 + Write-TestResult -Name "Silent uninstall completed" -Pass $uninstallOk -Detail "Exit code: $($uninstProc.ExitCode)" + + Start-Sleep -Seconds 2 + $dirGone = -not (Test-Path $DefaultInstDir) + Write-TestResult -Name "Install directory removed after uninstall" -Pass $dirGone +} +else { + Write-TestResult -Name "Silent uninstall completed" -Pass $false -Detail "Uninstaller not found" + Write-TestResult -Name "Install directory removed after uninstall" -Pass $false -Detail "Skipped" +} + +# ── Summary ────────────────────────────────────────────────────────────────── + +Write-Host "" +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host " Test Summary" -ForegroundColor Cyan +Write-Host "=============================================" -ForegroundColor Cyan +Write-Host " Passed : $Script:Passed" -ForegroundColor Green +Write-Host " Failed : $Script:Failed" -ForegroundColor $(if ($Script:Failed -gt 0) { "Red" } else { "Green" }) +Write-Host " Total : $($Script:Passed + $Script:Failed)" -ForegroundColor White +Write-Host "" + +$Script:Results | Format-Table -AutoSize + +# Export results as CSV for retrieval +$csvPath = Join-Path $ScreenshotDir "test_results.csv" +$Script:Results | Export-Csv -Path $csvPath -NoTypeInformation +Write-Host "Results saved to $csvPath" -ForegroundColor DarkGray + +exit $(if ($Script:Failed -gt 0) { 1 } else { 0 })